diff --git a/AGENTS.md b/AGENTS.md index 9d7e4efd67..3279e38a65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,10 +55,36 @@ No commented-out code—delete dead code. ### Codegen Implementation +- **Critical — finish work during generation**: Use the complete design, generation plan, + and Go templates to decide everything that is known before the generated + program runs. Select branches, names, types, imports, field paths, helper + calls, and emitted files while generating source. Templates must write only + the selected code. Do not make generated programs inspect generated type + shapes, parse generator-made names, carry generator mode flags, or execute + branches whose answer was already known. Runtime code should contain only + logic that depends on actual runtime values. When a runtime value is truly + required, keep that input narrow and specialize all surrounding code during + generation. - **Use NameScope helpers** for type references: `GoTypeRef`, `GoFullTypeRef`, `GoTypeName`. Never concatenate strings for types. - Let Goa decide pointer/value semantics. Do not force `pointer=true` except in transport validation. - **Keep helper visibility minimal**: If logic is shared only inside one codegen area, keep it package-private or move it under an `internal` package. Do not export helpers from a parent package just to share them across sibling generators. - **Avoid pass-through wrappers**: When two helper functions differ only by forwarding arguments or hard-coding `nil`, collapse them into a single implementation instead of adding an extra layer. +- **Generated packages own names**: When several services or plugins write to + one Go package, collect every package-level name before rendering and then + make those names final. A declaration and every HTTP, gRPC, or JSON-RPC use + of it must read the same name record. Do not give each service or plugin a + separate name scope for the same package, and do not add declarations after + names become final. +- **Keep identity typed and explicit**: Do not hide a declaration's kind, + package, or use in a decorated name or a made-up string map key. Do not + change an expression's `Hash` behavior to solve a generation problem. Pass a + typed identifier where the generated declaration is named. +- **Trace the complete lifecycle**: Before changing relocated types, union + naming, generation roots, plugins, or file merging, follow the declaration + from the evaluated design through service analysis, its generated Go + package, the emitted service code, HTTP and gRPC uses, plugin changes, and + the final file merge. A service-only rendering test is not enough. + See [`codegen/ARCHITECTURE.md`](codegen/ARCHITECTURE.md). ### Documentation diff --git a/Makefile b/Makefile index c839d3adaf..94462f7536 100644 --- a/Makefile +++ b/Makefile @@ -32,8 +32,8 @@ PROTOC_DEST=$(GOBIN_DIR)/$(PROTOC_BIN) # Only list test and build dependencies # Standard dependencies are installed via go get DEPEND=\ - google.golang.org/protobuf/cmd/protoc-gen-go@latest \ - google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.12 \ + google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 all: lint test integration-test @@ -177,4 +177,3 @@ release-plugins: git tag v$(MAJOR).$(MINOR).$(BUILD) && \ git push origin v$(MAJOR) && \ git push origin v$(MAJOR).$(MINOR).$(BUILD) - diff --git a/codegen/ARCHITECTURE.md b/codegen/ARCHITECTURE.md new file mode 100644 index 0000000000..8d908c4df4 --- /dev/null +++ b/codegen/ARCHITECTURE.md @@ -0,0 +1,946 @@ +# Code Generation Architecture + +This document defines how Goa turns one evaluated design into generated files. +It is the contract for generator authors: one run prepares the design, builds +one retained plan, freezes every emitted name, and renders that exact plan. + +## Why this contract exists + +The original AURA failure produced a reference to one validation function name +and a declaration with another name. The function and its caller were emitted +into the same generated Go package, but separate analyses allocated their names +from separately initialized scopes. The first ownership work fixed concrete +service, HTTP, JSON-RPC, and gRPC cases by adding a generation-wide package +catalog and transport-local catalogs. It also exposed the remaining design +mistake: planning still records only selected type families, then rendering +rebuilds service and transport data and allocates other package-level names. + +For example, a render-time service scope can still choose names for endpoint +constructors, error constructors, validators, and stream helpers after the +generation has supposedly frozen. A second `NewServicesData` call can rebuild +the same logical wire model with a different traversal context. Both behaviors +let a declaration and its reference disagree. + +The terminal contract is stronger and smaller: every package-level type, +function, constant, and variable has one declaration record owned by the +package that emits it. Every subsystem retains the analysis that created those +records. Rendering reads that analysis; it never reconstructs it. + +## Run lifecycle + +The `goa` command compiles and runs a temporary generator for one evaluated +design. One run follows this order: + +1. Resolve the command and create fresh core generator and factory-plugin + objects. Copy plugins registered through the released callback API into the + same run. +2. Evaluate and validate the design roots. +3. Run preparation plugins, which may add or change expressions. Construct one + `codegen.Generation` with exclusive access to those evaluated roots. Its + final preparation step wraps raw method attributes and records each exact + generated wrapper. Concurrent runs use distinct expression graphs; the + generation does not copy the graph or coordinate two runs mutating the same + unprepared objects. +4. Record the prepared roots for mutation auditing. No later phase may mutate + an expression root. The audit compares retained semantic state after every + later callback; it does not claim that the expression graph was physically + copied or made immutable. +5. Build one typed `generator.Plan`. It creates and retains the core service + plan for each root, then the selected HTTP, gRPC, JSON-RPC, OpenAPI, and + example plans that consume those exact service plans. Factory plugins + receive the same plan when they declare their output. +6. Each subsystem completes collection, sorts declarations by stable typed + identity, and declares every package-level symbol in its actual output + package. The generation then freezes package names and import qualifiers. +7. Link each retained subsystem plan once. Linking converts recorded design + facts and final declaration references into template data. All generated + names are fixed, but later plugin callbacks may still make the permitted + edits to ordinary section values. Linking cannot discover a declaration, + reserve a name or import, mutate an expression, or create another analysis + graph. +8. Core generators and factory plugins render from the same `generator.Plan` + and exact core service plans. Released callbacks receive their original + generated package, roots, and current file list instead. +9. Merge contributions with the same canonical output path and render files. + +Collection must be complete before freeze. Stable ordering makes preferred-name +suffixes independent of map iteration, traversal order, plugin registration +order, and process history. Freeze turns every declaration record into a +read-only value. Linking resolves those records exactly once before rendering; +it does not repeat collection or allocate another name. Render performs no +expression mutation, graph analysis, declaration discovery, name allocation, +or import allocation. + +## Fresh run objects + +Registration stores immutable factories, not mutable generator or plugin +instances. A factory is called once for each generation run: + +```go +type Plugin struct { + Prepare PrepareFunc + Plan func(*Plan) error + Generate func(*Plan, []*codegen.File) ([]*codegen.File, error) +} + +type PluginFactory func() Plugin + +func RegisterPlugin(name, command string, factory PluginFactory) +func RegisterPluginFirst(name, command string, factory PluginFactory) +func RegisterPluginLast(name, command string, factory PluginFactory) +``` + +These factory APIs belong to `codegen/generator`, which runs generation +commands. Goa also keeps the released four-argument registration functions in +`codegen`. Those functions store callback pairs in an internal registry; the +generator copies them into the same run when generation starts. Plugin authors +cannot inspect the registry or run callbacks themselves. Core generator +factories follow the same fresh-instance rule. + +Factory plugin names are non-empty and unique within one command across the +First, normal, and Last groups. Released callback registrations may repeat a +name, as Goa v3 allowed; equal names keep registration order. Both APIs reject +unknown commands and stop accepting registrations when generation first +starts. + +A factory may close over immutable configuration. Per-run roots, plans, files, +caches, and errors belong to the returned object. Concurrent and repeated +generation runs must not observe one another. The factory registry is immutable +while runs execute, and tests install isolated registries. The released +`Generators` variable remains replaceable for compatibility. Callers configure +it before starting concurrent runs; each run reads its function list once and +turns that list into fresh internal generators. + +## The retained core plan + +`generator.Plan` is the typed value shared by core generators and plugins. Its +fields are private. It exposes the active `Generation` and the exact service +plan built for a registered root: + +```go +func (p *Plan) Generation() *codegen.Generation +func (p *Plan) Service(root *expr.RootExpr) *service.Plan +``` + +Selected core subsystems are stored in typed fields, not in a generic map. +There is no `PlanKey`, string key, extension registry, or `any`-typed plan bag. +A plugin that needs core service declarations consumes `Plan.Service(root)`; +it may not call service analysis again or rebuild an equivalent plan from the +root. + +Each subsystem has one retained planning entry point. Service files can be +shared by several Goa roots in one generation, so service planning accepts the +complete root batch: + +```go +func service.NewPlans(generation *codegen.Generation, inputs ...service.PlanInput) ([]*service.Plan, error) +func service.NewPlan(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator) (*service.Plan, error) +``` + +`NewPlans` requires every service root owned by the generation exactly once. +It assigns relocated declaration files and external conversion methods across +the complete run before names freeze. Exact compiler copies with the same +retained Go layout share one declaration; copies that bind one declaration to +different fields, tags, pointer policies, union branches, or file facts are +rejected. `NewPlan` is only the strict single-root convenience form and rejects +a generation that contains more than one service root. + +HTTP, gRPC, JSON-RPC, OpenAPI, and example generation use equivalent typed +constructors. A transport plan receives the exact `*service.Plan` for its root. +JSON-RPC may retain and reuse its HTTP plan because it emits HTTP codecs and +wire files, but it does not rebuild HTTP analysis. Render functions accept the +retained subsystem plan, not a `Generation`, generated module path, expression +root, or reconstructed `ServicesData`. + +The plan stores collected design facts, linked render data, and final +declaration pointers. It does not store callbacks that repeat +analysis. `NewServicesData`, `Genfunc`, the replaceable `Generators` variable, +`renderOnly`, and the released functions that ran plugin callbacks are old +entry points that the retained plan replaces. + +## Generated package ownership + +The generation owns a package catalog keyed by the actual generated Go import +path. Each package record owns: + +- one declaration namespace for every package-level type, function, constant, + and variable; +- one import qualifier for every complete import path referenced by files in + that package; and +- the canonical output directory that corresponds to its import path. + +The common declaration record is `NameDeclaration`. It keeps its preferred and +final spellings private. `Name()` panics before freeze and returns the same +final spelling for the remainder of the run after freeze. Existing type, +union, branch, HTTP wire, protobuf, validator, and helper records contain or +reference `NameDeclaration`; they do not carry another independently mutable +name. + +Package-level declarations include less obvious symbols: constants that record +a union's selected branch, union constructors, endpoint constructors, error and +result constructors, validation functions, conversion functions, stream +interfaces and helpers, HTTP body constructors, protobuf oneof wrappers, +client and server constructors, and package variables emitted by templates. +Local variables, parameters, struct fields, and method names remain owned by +their lexical render scope because they cannot collide with package-level +declarations. + +Service package paths are assigned once across every prepared design root in a +generation run. Equal authored service names share one generated package even +when they come from different APIs. Different names that reduce to the same Go +package name receive stable numeric suffixes. Natural package names are +reserved first, so an authored service whose normal package is `read_value2` +keeps that path while another collision advances to `read_value3`. The linked +service data exposes the final directory as `PathName`. HTTP, gRPC, JSON-RPC, +MCP, examples, and command-line generators read that retained path and its +saved import; they never rebuild a package path from the service name. + +Generated command-line parsers also plan names at the scope that Go checks: +the complete `ParseEndpoint` function. The parser reserves imported package +names first, then parameters and fixed local variables, then each command's +flag variables and conversion variables. Templates receive those exact names +and write the selected conversion directly. They do not search generated text, +replace variable names, or decide a conversion from a type name at runtime. + +### Exact and preferred symbols + +An exact symbol is part of an authored or external contract. Two distinct +exact declarations that normalize to the same Go identifier in one package are +rejected before rendering. Examples include two relocated authored types named +`foo-bar` and `foo_bar`, or two explicit external names that both require +`FooBar`. + +A preferred symbol is generated from a semantic role. It may receive a stable +numeric suffix when another declaration already owns the preferred spelling. +Examples include a generated `ValidatePayload`, `NewValueText`, or protobuf +request message. The declaration's typed identity—not discovery order—decides +which record receives each spelling. + +Exact declarations reserve first. Preferred declarations are sorted by stable +typed identity and allocated second. A subsystem must reject two distinct +identities whose ordering facts are equal; pointer addresses, expression +hashes, map order, and rendered text are not tie-breakers. + +A companion whose spelling includes another declaration, such as +`Validate`, is registered as a dependent declaration before freeze. +The package freezes the base declaration first, then derives and reserves the +companion from that exact final name. Callers never rebuild the companion by +concatenating a separately resolved type string. + +### Imports and output paths + +Complete import path is the only import identity. Static-template requirements +have priority over generated-package preferences, which have priority over +design metadata preferences. References and `ImportSpec` values consume the +same frozen binding, while each file imports only the paths it uses. + +The output planner canonicalizes both generated import paths and filesystem +paths before collection. If two different package identities normalize to the +same import path or output directory, planning rejects them. It does not let +one package win, merge their declarations, or add a suffix to a directory. +Multiple file contributions may share a canonical path only when they declare +the same package identity. The file merger appends every body section and runs +every file finalizer in contributor order. It rejects conflicting package +headers, import bindings, or keep-existing-file settings instead of choosing a +contributor. + +Planning claims packages with the exact raw path supplied by the owner. The +claim is validated as a legal Go import path and preserves enough information +to reject two distinct raw paths that normalize to one import or output +directory. After freeze, generators use only canonical-path lookup; every +claim, including a repeated claim, is rejected because collection is closed. + +## Expression identity and declaration identity + +Expression identity answers a design question. Declaration identity answers +whether two generated package-level symbols are the same emitted contract. +They are deliberately separate. + +`UserType.Origin()` identifies the first declaration in one family of exact +copies. A generated transport type may start a new family, and renaming a type +deliberately starts another one. Goa uses this identity while binding types, +normalizing designs, planning service and transport declarations, creating +protobuf messages, transforming values, validating fields, building views, +and generating examples. It keeps unrelated same-named types separate, but it +does not by itself prove that two generated declarations have the same fields +or behavior. + +An emitted declaration identity contains every fact that changes its generated +source: owning package and role, source provenance, wire shape, validation, +defaults, views, pointer policy, ordered union branches, and protocol-specific +metadata as applicable. Equal semantic `ID()` values do not merge distinct +origins. Conversely, the same authored origin may produce distinct request and +response records when their emitted contracts differ. + +`expr.Union.Hash()` remains expression identity. Typed code-generation +identities such as `UnionTypeID` describe emitted union families. Do not change +expression hashes, decorate string keys, or add general expression provenance +to coordinate code generation. + +## Example value identity + +Example configuration is immutable. One generation run creates an unanchored +`ExampleGenerator` for each prepared root, and every value draw must first +select a typed `ExampleIdentity`. The public identity constructors accept the +owning evaluated expression: a user type, method payload or result, method +error, HTTP request body, successful HTTP response, or HTTP error. Callers do +not join service names, response positions, or role labels into seed strings. + +Structural descent is also typed. Object members, array elements, map keys, map +values, and union branches use distinct kind-tagged, length-framed segments. +For example, object member `"0"` cannot share a stream with array element zero, +and a result field named `NotFound` cannot share a stream with the method error +named `NotFound`. Length-constrained arrays and maps derive one stream per +element, key, and value instead of consuming a shared stream in traversal +order. + +Named user types own their examples globally. Anonymous request, response, and +streaming shapes retain the explicit method or transport owner passed by their +caller. A zero-value generator intentionally disables OpenAPI examples; every +configured but unanchored value draw panics because it violates the identity +contract. + +## Service plan + +`service.Plan` owns every service and views package declaration for one root. +Its constructor collects service declarations, normalized method wrappers, +relocated authored types, projected view types, unions and their complete +families, endpoints, clients, constructors, validators, conversions, +interceptors, errors, stream types, and package variables. It also collects the +imports and exact output files those declarations require. + +The plan retains one package-backed attributor for each service and views +package. HTTP, gRPC, JSON-RPC, example, and plugins use those attributors and +canonical declaration records. No consumer recreates a service `NameScope`, +calls `NewServicesData`, or reconstructs a name from a DSL spelling and package +alias. + +When multiple prepared roots contribute to one generated package, the core +plan collects all their declarations before the package freezes and emits the +package once. A root not present in the Generation snapshot is rejected. + +## Value conversion plans + +`TransformPlan` records one conversion from a source Go value to a target Go +value. Creating the plan copies both type graphs, finds every recursive helper +function the conversion will call, and records the choices made by hooks that +change the type shape. Later edits to the design types or hook value cannot +change the planned conversion. Two generated copies remain separate even when +they came from the same authored type; only an edge back to the exact copied +value closes a recursive cycle. + +Planning hooks may return a different source or target attribute for one +conversion step, but they must not edit either graph they receive. The planner +checks this immediately after each hook returns, including changes to nested +defaults and metadata, and rejects the plan when a hook mutates its input. +This makes the returned choice explicit without letting one hook change what a +later hook or the renderer sees. + +The caller declares every helper function in the package that will contain it, +binds those declarations and the final source and target type resolvers, and +then renders the conversion. Planning and rendering therefore use the same +function declarations and final package qualifiers. `GoTransformWithAttrs` +keeps its released interface, but now performs these same steps internally and +returns the released helper data after rendering once. + +`Helpers` returns detached type descriptions together with plan-owned helper +IDs. A caller may inspect or change those descriptions while choosing a +function declaration, but cannot change the type graph used for rendering. +Rendering also rejects a hook that changes the retained graph. When one plan is +rendered again with the same source variable, target variable, and assignment +choice, it returns a copy of the first result instead of calling hooks again. +This lets one conversion plan serve repeated template requests without letting +hook state change previously generated code. + +Most conversions can discover their helper calls from the source and target +types. A custom union renderer that calls helpers itself must also implement +`TransformHooks.PlanUnionHelpers`. That hook records the exact branch pairs for +which the renderer will request helpers. This keeps union rendering +specialized: generated code contains only the branch conversions selected +during generation and performs no runtime lookup by branch name or package. + +## HTTP and JSON-RPC plans + +Each actual HTTP client or server output package owns a retained wire plan. It +collects complete detached request, response, WebSocket, SSE, error, union, +constructor, validator, codec, and helper declarations before freeze. The plan +keeps request and response policy in declaration identity, so one authored +origin can reuse a record only when the complete emitted wire contract agrees. + +HTTP transforms enter the service and wire owners independently. Detached HTTP +bodies do not carry service package metadata. JSON-RPC consumes the exact HTTP +plan for the files and codecs it shares, then adds its own package declarations +to typed JSON-RPC plans. It does not create a second HTTP catalog. + +Every HTTP and JSON-RPC output file records the service, views, and authored +package paths it will use before package names become final. Linking resolves +only those saved paths to their final qualifiers; it does not inspect design +expressions again or rebuild a service path from its name. Public plan snapshots +return detached nested values, so changing a snapshot cannot change a later +read or rendered file. + +A method with separate ordinary and streamed results plans the streamed SSE +body independently. When the retained service value and HTTP body have the +same Go layout, the client assigns the decoded value directly. When their Go +layouts differ, the client validates the decoded body and uses the exact saved +conversion. An empty streamed result emits its explicit zero-value return. +These choices are made while generating source; the generated client does not +inspect types or select a conversion at runtime. + +Every validator and helper reference stores its canonical declaration. A call +site's traversal context may select which declaration it needs, but it never +selects or changes that declaration's name. + +Reusable API- and service-level HTTP or gRPC error mappings select response +policy by error name. The endpoint method's effective error declaration owns +the service value that encoders and decoders carry. Planning compares pure, +fully finalized copies of the mapping and method attributes, including emitted +type shape, validations, defaults, struct metadata, and ordered union branches. +It accepts equivalent declarations and binds the mapping to the method record; +it rejects incompatible shadowing before rendering without mutating the +evaluated design. + +## Protobuf and gRPC plans + +The protobuf plan owns a descriptor model for each emitted `.proto` package and +the corresponding Go package produced by the supported `protoc` and +`protoc-gen-go` toolchain. Protobuf source declarations and protoc-generated Go +declarations are different, explicit families. + +A declaration family records every package-level Go symbol Goa refers to, +including messages, nested messages, enums and enum values, oneof interfaces +and wrapper structs, service interfaces, client and server types, and version- +dependent support symbols. Preferred protobuf names do not become identity. +Field numbers, ordered fields and oneof branches, validation, defaults, source +provenance, and endpoint role are identity facts where they change output. + +The protoc Go naming algorithm is selected by an explicit supported toolchain +version. One versioned implementation derives Go names for a descriptor family; +templates and transforms do not carry scattered approximations of protoc +CamelCase or oneof naming. Changing the supported compiler or plugin version +requires a new versioned naming contract and generated-module proof against the +real toolchain. + +gRPC validators and conversions consume frozen descriptor-family records. +Validator identity is independent of the call site that first discovers it, +and conversion contexts cannot allocate a message, wrapper, or validator name. +Explicit metadata remains a detached native primitive wire contract and uses +the canonical service transform after parsing or before serialization. + +## Plugins + +Preparation is the only plugin phase allowed to mutate expression roots. A +plugin that adds a service, method, type, or transport mapping attaches it to a +registered root during preparation. Core normalization then observes it before +planning. + +After adding services and any new user types to that root, the preparation +plugin calls `(*expr.RootExpr).EvaluateAttachedServices`. This checks that the +new expressions belong to the same root, prepares and validates all of them, +and finishes none of them when any one is invalid. It is the public operation +for adding evaluated services; plugins must not run individual expression +steps or use the package-global root. + +Factory plugin planning receives `*generator.Plan`. It may declare plugin-owned +output through the same Generation, and it consumes core declarations through +the exact retained service plan. Factory plugin rendering receives the same +plan after names are final. It may add files and sections, but it cannot create +another root, re-run service or transport analysis, reserve a name, or change +an expression. Released callbacks keep their original arguments and do not +gain a planning phase. + +An HTTP plugin calls `Plan.HTTP(root)` with the exact prepared service root it +received. The method returns the ordinary HTTP plan for that root. It returns +false for a different root value and for a root that only has JSON-RPC methods. +During `Plugin.Plan`, the plugin may declare an exported server handler wrapper +for an exact HTTP service, an unexported handler wrapper for one exact HTTP +endpoint, or an extra server mount for an exact HTTP service in that plan. Goa +submits those function names to the service's generated server package, so +collisions are settled with Goa's own names before source is written. + +A declared handler wrapper has the shape `func(http.Handler) http.Handler`. +Goa writes direct nested calls inside each exported endpoint and file mount +helper. Direct callers of those helpers therefore receive the same wrapping as +callers of the service's `Mount` function. Endpoint handlers, file handlers, +and redirects defined by the design are covered, and the first declared +wrapper is the outermost. The service's `Mount` function passes each handler to +its helper unchanged, so wrappers run exactly once. +The linked HTTP service data retains that exact declaration list and copies it +into each generated endpoint and file mount helper, so plugin output can match +the declaration by pointer even for a service that contains only files. +An endpoint handler wrapper also has the shape +`func(http.Handler) http.Handler`, but Goa writes it only in that endpoint's +exported mount helper. Service wrappers surround endpoint wrappers. File mount +helpers receive service wrappers only. This lets a plugin add behavior to one +designed endpoint without changing another endpoint, a file route, or an extra +plugin mount. +An extra server mount has the shape `func(goahttp.Muxer)` and supplies the +method label, HTTP verb, and path that Goa adds to the generated server's +`Mounts` list. Goa calls extra mounts after routes from the design, in +declaration order. Extra mounts are separate calls and are not passed through +the declared handler wrappers. Both declarations must happen before generation +freezes; later calls, JSON-RPC plans, foreign services, missing route fields, +and changes to the linked declarations are generation errors. + +MCP generation therefore attaches its generated service expressions during +prepare and later consumes `Plan.Service(root)`. Agent tool specifications use +one retained typed specification plan for each output package; public specs and +transport specs are distinct packages with distinct declaration owners. No +plugin coordinates through a process-global map, a latest result, a decorated +hash, `PlanKey`, or render order. + +When a plugin needs a generated payload field, it asks the retained service +plan for `MethodPayloadLayout`. The returned `GoTypePlan` contains the exact Go +field spelling and pointer choice already selected by service generation. For +example, a design field sent as JSON `cursor` may be generated as +`OriginalCursor` because of field metadata; the plugin keeps `cursor` on the +wire and emits `payload.OriginalCursor`. It must not call `Goify`, inspect the +JSON name, or repeat the service pointer rules. + +## File assembly + +`SectionTemplate.Name` labels a section for diagnostics. It is not declaration +identity. Package plans remove identical declaration contributions before they +become sections. The file merger combines imports and appends every non-header +section in producer order, even when diagnostic labels match. Conflicting +declarations remain visible and fail during planning or Go compilation instead +of disappearing silently. + +## Compatibility and operations + +Goa keeps the released callback registration API alongside the retained-plan +API. Both registration styles enter the same generation run, use the same +prepared designs and current file list, and run in the released first, normal, +and last order. The released API still accepts the same name more than once for +one command. Registrations with the same position and name run in registration +order, matching released Goa v3. Factory plugin names remain unique, and Goa rejects +a released registration whose command and name match a factory registration so +the same plugin cannot run twice through two APIs. Goa does not restore the +released functions that ran plugins; the generator remains the only code that +executes them. + +This compatibility is deliberately limited. A released preparation callback +may change a design before Goa chooses names. A released generation callback +may edit ordinary values and remove or reorder files, sections, or list +entries. A nil file entry is ignored for every list size. Released Goa ignored +nil among several files but accidentally panicked when nil was the only file. +Goa's own templates read declarations chosen during planning rather than the +released string copies, so changing only a released name field does not rename +Goa's code. The public strings remain final name snapshots for existing plugin +templates. A plugin may deliberately replace declaration fields, sections, +templates, source, or file finalizers; as in released Goa, that plugin owns the +correctness of the resulting source. + +An ordinary callback error is returned unchanged and stops the run. If the +same callback also changes a prepared design after planning, Goa reports that +forbidden change instead. The changed root remains visible after the failed +run, so hiding it behind the callback error could corrupt a later run. + +A plugin that adds package declarations or chooses Go names must use a +`generator.PluginFactory` and declare that work through `Plugin.Plan` before +names become final. Rebuilding Goa's private service or transport records is +not supported; plugins should render their own typed values instead. Upgrade +those plugins with Goa, then regenerate the whole generated tree from an empty +output directory. + +A gRPC plugin that renders viewed-result conversions must use +`ResponseData.ServerConverts` and `ClientConverts`, or +`StreamData.SendConverts` and `RecvConverts`, as appropriate. The singular +conversion fields remain available for existing templates, but they describe +the default or fixed view only. Reusing one singular conversion for every view +can read a field that the selected view deliberately omitted. + +The remaining breaks are limited to generation and generated source except +where the runtime changes are listed below. There is no persisted-data +migration or staged generator mode. Already compiled programs do not start +using the new generator merely because the Goa module is updated. + +### Generator library migration + +The following table lists the preserved, removed, or signature-changed +exported APIs in this change. “No direct replacement” means callers must stop +performing that step; the run or the owning retained plan now performs it once. + +| Package | Old API | New API or required change | +| --- | --- | --- | +| `codegen` and `codegen/generator` | `codegen.RegisterPlugin`, `RegisterPluginFirst`, and `RegisterPluginLast` accepted prepare and generate callbacks | These four-argument functions remain available. Goa runs their callbacks in the same run as plugins registered through `codegen/generator`. Registration now panics for an empty name, an unknown command, a nil generate callback, or a call made after generation has started. Use the factory API when a plugin must plan new package declarations or read service and transport plans from the current run. | +| `codegen` | `PrepareFunc`, `GenerateFunc`, `RunPluginsPrepare`, and `RunPlugins` | `PrepareFunc` and `GenerateFunc` remain available for registration. The two run functions have no replacement because `generator.Generate` owns the complete plugin lifecycle. | +| `codegen/generator` | `Genfunc`, the replaceable `Generators` variable, and the exported `Example`, `Service`, `Transport`, and `OpenAPI` functions | `Genfunc` remains as an exported function type so existing declarations compile, but the generator command no longer accepts or calls it. The variable and four core functions have no replacement because calling or replacing them would split one generation into separate name plans. Register a plugin factory when adding output; the command chooses and runs the core generators. | +| `codegen` | `NormalizeRoot` | No direct replacement. `codegen.NewGeneration` performs normalization once and records the exact generated method types. | +| `codegen` | `AddServiceMetaTypeImports` | No direct replacement. The owning service or transport plan records the imports used by each output file. | +| `codegen` | `NewAttributeContextForConversion` | No direct replacement. Build and bind a `TransformPlan`; its source and target contexts retain the correct package owners. | +| `codegen` | Custom `Attributor` implementations needed only `Scoper`, `Name`, `Ref`, and `Field` | Implement `Package`, `Enter`, `IsSumType`, and `ValidatorCall` too. These methods make package ownership and exact validator calls explicit. | +| `codegen` | `AttributeContext.DefaultPkg` and `SamePackageConversion` | Removed. Package lookup belongs to the `Attributor`; enter the source or target attribute instead of setting a default package or a same-package mode. `ArrayElementPointer` is new and is only for a wire array that must distinguish JSON `null` from a primitive zero value. | +| `codegen` | `TransformHooks.HelperNameAttrs` | Removed. Bind each recursive helper through `TransformPlan.Helpers` and `BindHelperDeclaration`; do not derive a helper name from a second attribute walk. A custom `TransformUnion` that calls `TransformHelperName` must also implement `PlanUnionHelpers` so planning can declare those functions before rendering. | +| `codegen` | `WrapDirective.InitTypeName` | Set `WrapDirective.Target` to the wrapper attribute. Rendering resolves its already planned Go type name. | +| `codegen` | `TransformFunctionData` contained only `Name`, parameter and result references, and code | It now also identifies the planned helper with `ID` and `Declaration`. `Name` remains as a deprecated copy of `Declaration.Name()` for every rendered helper. Unkeyed struct literals must be updated. | +| `codegen/cli` | `BuildCommandData(data)`, `EndpointParserFile(..., data, parseSection)`, `UsageCommands(data)`, `UsageExamples(data)`, and `FlagsCode(data)` | These released signatures and section data remain available. Goa's HTTP and gRPC planners call private planned variants that carry final import, declaration, and local-variable names. | +| `codegen/cli` | `BuildFunctionData.Name`, `ActualParams`, and `FormalParams` | These fields remain and contain the final generated name and parameter lists. New planning code may also read `BuildFunctionData.Declaration`. | +| `codegen/cli` | `NewFlagData` accepted a type name; `FieldLoadCode` accepted type and validation source strings; `FlagArgData.TypeName` and `Validate`; positional `FlagData` literals | The released functions and fields remain available and keep their string-based behavior. Goa's transport planners use one opaque `FlagPlan`, created by `NewFlagPlan`, so validation is written against the exact parsed value without rewriting generated text. Supplying both `Validate` and `Plan` is rejected. Use named fields when constructing `FlagData` because it now contains private planning state. | +| `codegen/example` | Global `Servers`, `ServersData`, `ServersData.Get`, and `APIPkg` | No direct replacement. Create `example.Plan` with `example.NewPlan`, then get its copied `example.Root`. Package names come from the associated service plan. The pure `RootPath(genpkg)` helper remains available. | +| `codegen/example` | `CLIFiles(genpkg, root)` and `ServerFiles(genpkg, root, services)` | Call `CLIFiles(root)` and `ServerFiles(root, services)` with the `*example.Root` returned by the retained example plan. | +| `codegen/example` | `VariableData.VarName`; `HandlerArg.Endpoint` and `Service` contained generated local variable names | `VariableData.VarName` is removed. `HandlerArg.Service` contains the design service name, `Endpoint` reports whether the endpoint collection is needed, and `Variable` contains the final local variable name after the example plan is linked. `Data` also reports whether the server uses HTTP or JSON-RPC. | +| `codegen/service` | `NewServicesData` | Create one `codegen.Generation`, then call `service.NewPlans` for the complete root batch. Use `service.NewPlan` only when the generation has exactly one service root. After freeze and `Link`, use `Plan.Services`. | +| `codegen/service` | `ClientFile`, `EndpointFile`, `ConvertFiles`, `InterceptorsFiles`, and `ViewsFile`; `Files(genpkg, service, services, userTypePkgs)` | No direct per-file replacement. Call `service.Files(plans...)` after every supplied plan is linked and handle its `([]*codegen.File, error)` result. The plans decide the complete file set. | +| `codegen/service` | `SetUserTypeImports`, `AddServiceDataMetaTypeImports`, and `AddUserTypeImports` | No direct replacement. Imports are retained per file by the service plan. | +| `codegen/service` | `ExampleServiceFiles(genpkg, root, services)` and `ExampleInterceptorsFiles(genpkg, root, services)` | Pass the linked `*service.Plan` to `ExampleServiceFiles(plan)` or `ExampleInterceptorsFiles(plan)`. | +| `codegen/service` | Public render-data fields `Data.UserTypeImports`; `MethodData.IsJSONRPC`, `IsJSONRPCSSE`, and `IsJSONRPCWebSocket`; `EndpointMethodData.IsJSONRPC`, `IsJSONRPCSSE`, and `IsJSONRPCWebSocket`; and `StreamData.SendAndCloseName`, `SendAndCloseDesc`, `SendAndCloseWithContextName`, and `SendAndCloseWithContextDesc` | Removed. Factory plugins inspect the HTTP or JSON-RPC plan's endpoint data. Released service-template plugins must update templates that read these fields. There is no JSON-RPC WebSocket replacement because design validation rejects that transport combination. `EndpointsData.VarName`, `ClientVarName`, and `ServiceVarName` and `EndpointMethodData.ClientVarName` and `ServiceVarName` remain as deprecated copies of their final declarations. `Data.ViewsPkg` and `ProjectedTypeData.ViewsPkg` remain available when Goa generates a views package. `ErrorInitData.Name`, `InitData.Name`, and `ValidateData.Name` also remain as deprecated copies of real planned declarations. A custom error has no generated service constructor, so both its constructor declaration and compatibility name are empty. `ValidateData` contains function calls now, so it cannot be compared with `==` or used as a map key. | +| `codegen/service` interceptor section data | Interceptor wrapper sections exposed `map[string]any` values with keys such as `Method`, `Service`, and `Interceptors` | The sections now use private typed data built for the exact method and call kind. Plugins that replace only a section's source must update to the current section contract; plugins cannot type-assert the old map. This lets generated accessors use exact payload, result, and stream types without runtime method checks. | +| Generated service interceptors | An interceptor method accepted `info *NameInfo`, where `NameInfo` was an exported struct with private fields | The method accepts `info NameInfo`, where `NameInfo` is an interface with the same public accessor methods. Update handwritten interceptor signatures by removing `*`. Goa now writes a private implementation for each service method and call kind, so payload and stream accessors use the exact generated types without inspecting the method at runtime. | +| `expr` and `dsl` | `APIExpr.ExampleGenerator`; `dsl.Randomizer(expr.Randomizer)`; `NewRandom` | Store an immutable `APIExpr.RandomizerFactory`. Pass `NewFakerRandomizerFactory` or `NewDeterministicRandomizerFactory` to the DSL. For direct example generation, call `NewExampleGenerator(factory).At(identity)`. The standalone `NewFakerRandomizer` and `NewDeterministicRandomizer` constructors remain available. | +| `expr` | Exported concrete `FakerRandomizer` and `DeterministicRandomizer`; the embedded `ExampleGenerator.Randomizer`; `ExampleGenerator.Derived`, `Rebased`, `Field`, `PreviouslySeen`, and `HaveSeen` | The two standalone randomizer types and their released constructors remain available. Generation uses `RandomizerFactory` so every typed example identity receives a fresh value sequence. The embedded stream and recursion methods are removed; select a public typed `ExampleIdentity`, then descend with `Member`, `ArrayElement`, `MapKey`, `MapValue`, or `UnionMember`. | +| `expr` | Custom implementations of `UserType` | Add `Origin() UserType`. A copy returns the first declaration in its current family. An independently created or intentionally renamed type returns itself. Goa uses this identity to recognize copies without treating unrelated types with the same name as one type. | +| `expr` | Repeated calls to `(*AttributeExpr).Validate` in one process could skip errors reported by an earlier call | Every call now validates the supplied expression and reports its errors. Direct users must not rely on a previous call hiding a later validation failure. | +| `expr` | A non-pointer `ResultTypeExpr` value satisfied `UserType` through its embedded `*UserTypeExpr` | Use `*ResultTypeExpr`. Renaming a result must also clear the result's stored copy origin, so `Rename` now belongs to the pointer and a value no longer satisfies `UserType`. Ordinary result expressions were already created and passed as pointers. | +| `expr` | Preparation plugins added services and called expression steps themselves | After adding the services and any new user types to their owning root, call `(*RootExpr).EvaluateAttachedServices`. It prepares and validates the complete added set before finishing any of it. | +| `expr` | Positional struct literals for `ResultTypeExpr`, `SchemeExpr`, `ServiceExpr`, and `UserTypeExpr` | Use literals with named fields or the package constructors. These structs now contain private identity fields, so code outside `expr` cannot initialize every field by position. | +| `expr` | `UnionToObject` | No direct replacement. HTTP and gRPC plans retain their own wire representation for a union. | +| `codegen` | Comparing `TransformAttrs` values or using them as map keys | `TransformAttrs` now stores maps and function-planning state, so it is no longer comparable. Pass pointers or compare the specific public fields that matter to the caller. Positional literals also need to become named-field literals. | +| `grpc/codegen` | `NewServicesData(serviceData)` | Create `grpc/codegen.Plan` values with `NewPlans`. `ServicesData.GRPCServices` and `ServicesData.Get` remain available, but callers should read the instance built by the plan instead of rebuilding gRPC analysis. | +| `grpc/codegen` | `ClientFiles(genpkg, data)`, `ClientCLIFiles(genpkg, data)`, `ProtoFiles(genpkg, data)`, `ServerFiles(genpkg, data)`, `ServerTypeFiles(genpkg, data)`, and `ClientTypeFiles(genpkg, data)` | These released signatures remain available. They render the files already recorded by `data` and panic when `genpkg` differs from `data.GenPkg()`. New plugin code should prefer the corresponding linked `Plan` methods. | +| `grpc/codegen` | `ExampleCLIFiles` and `ExampleServerFiles` | Create an `ExamplePlan` with `NewExamplePlan`; call its `CLIFiles` and `ServerFiles` methods. | +| `grpc/codegen` | `EndpointData.ClientMethodName`, `MetadataData.Map`, `MetadataData.MapStringSlice`, and `ValidationData.Name` | All four remain as deprecated copies of final generated data. Valid designs now reject map-shaped gRPC metadata, so `Map` and `MapStringSlice` are false after successful generation. `InitArgData` and `MetadataData` now contain a validation function, so they cannot be compared with `==` or used as map keys. | +| gRPC generation tools | `protoc-gen-go` and `protoc-gen-go-grpc` were discovered by `protoc`; the Makefile installed their latest releases | Install `protoc-gen-go v1.36.12` and `protoc-gen-go-grpc 1.6.2`. Goa resolves those programs before planning and rejects another reported version or an attempt to replace them through `Meta("protoc:cmd")`. The exact pair is the tool contract currently covered by generated-module tests; version text alone is not a general proof that another binary would produce different or compatible declarations. | +| `http/codegen` | `NewServicesData` and `NewJSONRPCServicesData` | Create HTTP plans with `NewPlans` or JSON-RPC HTTP plans with `NewJSONRPCPlans`. Both require the exact `*service.Plan` for the root. | +| `http/codegen` | `ClientFiles`, `ClientEncodeDecodeFile`, `ClientCLIFiles`, `ServerFiles`, `ServerEncodeDecodeFile`, `ServerTypeFiles`, `ClientTypeFiles`, `PathFiles`, and `WebsocketClientFile` | These released signatures remain available. They return files already built by the retained plan and reject a generated package argument that differs from the supplied `ServicesData`. New plugin code should prefer the linked HTTP plan methods. | +| `http/codegen` | `ExampleCLI`, `ExampleCLIFiles`, `ExampleServer`, and `ExampleServerFiles` | Create an HTTP `ExamplePlan` and call its `CLIFiles`, `ServerFiles`, or `CombinedServerFiles` methods. | +| `http/codegen` | `OpenAPIFiles(root)` | Call `NewOpenAPIPlan(root, exampleGenerator)`, then `Files`. | +| `http/codegen` | `CreateHTTPServices` testing helper | No direct replacement. Tests must construct, freeze, and link service and HTTP plans like production. | +| `http/codegen` | `SSEData.DataFieldTypeRef`; `ServiceData.ServerTypeNames`, `ClientTypeNames`, and `UnionTypes` | `DataFieldTypeRef` remains as a deprecated copy of `SSEData.Data.TypeRef` for an explicitly mapped data field. The three service-wide type lists are removed; read the generated type declarations supplied by the linked HTTP plan. `AttributeData` now contains a validation function, so it cannot be compared with `==` or used as a map key. | +| `jsonrpc/codegen` | `ClientFiles`, `ServerFiles`, and `ExampleServerFiles` | Create and link a JSON-RPC plan; call its `ClientFiles` and `ServerFiles` methods. Use `NewExamplePlan` for example files. | +| `jsonrpc/codegen` | `CreateJSONRPCServices` testing helper | Use `CreateJSONRPCPlan` when a test needs the linked production plan. | +| `jsonrpc` | Positional literals for `RawRequest` | Use named fields. `RawRequest` now records whether JSON-RPC request validation failed so the server can return Invalid Request instead of treating the value as a notification. Existing named-field literals continue to compile. | +| `jsonrpc` | WebSocket `StreamConfig`, `StreamConfigOption`, `StreamErrorType`, `StreamErrorHandler`, `StreamErrorConnection`, `StreamErrorProtocol`, `StreamErrorParsing`, `StreamErrorOrphaned`, `StreamErrorTimeout`, `StreamErrorNotification`, `NewStreamConfig`, `WithRequestTimeout`, `WithConnectionTimeout`, `WithCloseTimeout`, `WithResultChannelBuffer`, `WithWebSocketBuffers`, `WithRetryConfig`, `WithCompression`, `WithPingInterval`, `WithErrorHandler`, and `(*StreamConfig).Validate` | No replacement. JSON-RPC WebSocket generation was removed. JSON-RPC supports unary HTTP calls and server streams through explicit server-sent events. | +| `codegen/service` | `UnionTypeData.Declaration` | Use `TypeDeclaration` for the generated value type and `KindDeclaration` for the type that records the selected branch. Each `UnionFieldData` now has `KindDeclaration` and `ConstructorDeclaration` for its generated constant and constructor. The old `Name`, `KindName`, `KindConst`, and `Constructor` strings remain final snapshots for existing plugin templates. | +| `http/codegen/openapi` | Process-global `Definitions`; `APISchema`, `GenerateServiceDefinition`, `ResultTypeRef`, `ResultTypeRefWithPrefix`, `TypeRef`, `TypeRefWithPrefix`, `GenerateResultTypeDefinition`, `GenerateTypeDefinition`, `GenerateTypeDefinitionWithName`, `TypeSchema`, `TypeSchemaWithPrefix`, `AttributeTypeSchema`, and `AttributeTypeSchemaWithPrefix` | The global definition cache and its mutating helpers have no replacement. For OpenAPI 2 attribute schemas, use `v2.BuildAttributeSchema(api, attribute, exampleGenerator)`; otherwise build a complete v2 or v3 document so definitions remain local to that build. | +| `http/codegen/openapi/v2` | `NewV2(root, host)` and `Files(root, path)` | These released signatures remain available and use the evaluated design's randomizer factory. Call `NewV2WithValues` or `FilesWithValues` when supplying translated values or a specific example generator. | +| `http/codegen/openapi/v3` | `New(root, version)` and `Files(root, version, path)` | These released signatures remain available and use the evaluated design's randomizer factory. Call `NewWithValues` or `FilesWithValues` when supplying translated values or a specific example generator. | +| `codegen`, `codegen/cli`, `codegen/example`, `codegen/service`, `expr`, `grpc/codegen`, and `http/codegen` | Positional literals for changed render-data structs | Use named fields or, preferably, the owning plan constructor. The exact affected types are listed below. Some now contain private state and cannot be fully constructed outside their package. | + +The following exported structs gained or replaced fields, so an unkeyed literal +that compiled with Goa v3 must be changed to named fields: + +- `codegen`: `AttributeContext`, `TransformAttrs`, `TransformFunctionData`, + `WrapDirective`. +- `codegen/cli`: `BuildFunctionData`, `CommandData`, `FlagArgData`, `FlagData`, + `InterceptorData`, `SubcommandData`. +- `codegen/example`: `Data`, `HandlerArg`. +- `codegen/service`: `EndpointMethodData`, `EndpointsData`, `ErrorInitData`, + `InitData`, `InterceptorData`, `MethodData`, `MethodInterceptorData`, + `ProjectedTypeData`, `ServicesData`, `StreamInterceptorData`, `UnionFieldData`, + `UnionTypeData`, `UserTypeData`, `ValidateData`, `ViewData`, and + `ViewedResultTypeData`. `UnionTypeData` replaces `Declaration` with + `TypeDeclaration` and `KindDeclaration`; `UnionFieldData` adds + `KindDeclaration` and `ConstructorDeclaration`. +- `expr`: `APIExpr`, `ResultTypeExpr`, `SchemeExpr`, `ServiceExpr`, and + `UserTypeExpr`. +- `grpc/codegen`: `EndpointData`, `InitArgData`, `InitData`, + `LegacyDecodeData`, `MetadataData`, `RequestData`, `ResponseData`, + `ServiceData`, `ServicesData`, `StreamData`, and `ValidationData`. +- `http/codegen`: `AttributeData`, `CookieData`, `Element`, `EndpointData`, + `FileServerData`, `HeaderData`, `InitArgData`, `InitData`, `JSONRPCBodyData`, + `MultipartData`, `ParamData`, `PayloadData`, `ResponseData`, `ServiceData`, + `ServicesData`, `SSEData`, `TypeData`, and `WebSocketData`. + +`TransformAttrs`, `codegen/cli.FlagArgData`, `service.ValidateData`, +`grpc/codegen.InitArgData`, `grpc/codegen.MetadataData`, +`grpc/codegen.StreamData`, and `http/codegen.AttributeData` now contain maps, +slices, or functions. They can no longer be compared with `==` or used as map +keys. + +Several exported template-data structures now carry `*codegen.NameDeclaration` +records so every use reads the exact name chosen during planning. Goa keeps +public name fields when they can be copied from one real declaration after +names are final. This includes HTTP names, service constructors and validators, +CLI payload builders, and gRPC client methods and validators. For HTTP data, +plugins may edit ordinary render values and remove or reorder entries. +Goa's templates read the declaration field for each generated name rather than +its released string copy. A planning plugin may create a simple template value +for its own declaration, such as an HTTP constructor or gRPC constructor or +validator. The returned file must be in the generated package that reserved +that declaration. + +The preserved HTTP name fields are `ServiceData.ServerStruct`, +`MountPointStruct`, `ServerInit`, `MountServer`, and `ClientStruct`; +`EndpointData.MountHandler`, `HandlerInit`, `RequestDecoder`, +`ResponseEncoder`, `ErrorEncoder`, `ClientStruct`, `RequestEncoder`, +`ResponseDecoder`, and `BuildStreamPayload`; `MultipartData.FuncName` and +`InitName`; `SSEData.StructName`; `FileServerData.MountHandler`; +`WebSocketData.VarName`; `InitData.Name`; and `TypeData.VarName`, +`ValidatorName`, and `NestedValidatorName`. Existing templates that read these +fields continue to work. Copied JSON-RPC service data also keeps +`ServerStruct`, `ServerInit`, `MountServer`, and `ClientStruct`. Copied +JSON-RPC endpoint data keeps `HandlerInit`, `ClientStruct`, `RequestEncoder`, +`RequestDecoder`, and `ResponseDecoder`. Each string contains the final name +from its declaration. A plugin that creates a new package-level declaration +must use the factory API and declare it during `Plugin.Plan`. +Typed service, endpoint, and file values copied from Goa keep the wrappers and +extra mounts saved with them. Plugins may still remove the files or sections +that render those values. +Plugins should use their own template values for plugin-owned types rather than +constructing Goa transport data. + +HTTP package definitions and uses read the same planned declarations. This +includes WebSocket stream types, request builders and conversion functions, +body types, and their public and nested validators. The released `VarName`, +`Name`, `ValidatorName`, and `NestedValidatorName` strings mirror those +declarations, but Goa's templates do not use those copies as declaration +identity. A primitive or inline composite Go type such as `string` or +`[]string` has no package declaration; +the plan records that complete type expression instead. JSON-RPC receives a +copy of the same body declaration when one exists. Request builders and body +conversion functions are declared before names freeze, including constructors +for inline request bodies, so another generator claiming the preferred name +changes both the generated definition and every call. + +HTTP client command sections also keep the released `MultipartFuncName` and +`BuildStreamPayload` strings. They copy the matching declarations after names +are final, while Goa's templates read the declaration records directly. + +gRPC preserves the same name snapshots. `ServiceData.ServerStruct`, +`ClientStruct`, `ServerInit`, and `ClientInit`; `EndpointData.ServerStruct`, +`ClientStruct`, `ClientBuild`, `ClientEncode`, `ClientDecode`, `ServerHandler`, +`ServerDecode`, and `ServerEncode`; `StreamData.VarName`; and +`LegacyDecodeData.FuncName` each contain a final planned name. Goa's own gRPC +templates read the declaration saved for each role. Plugin templates may +continue to read the public snapshots, and plugin-owned source remains the +plugin's responsibility. + +Other affected data includes `service.EndpointsData`, `EndpointMethodData`, +`ErrorInitData`, view and interceptor data; gRPC service, method, request, +response, and transform data; and CLI parser and payload-builder data. Call +`Name()` only after the generation is frozen. Existing unkeyed literals for +changed exported data must become keyed literals or, preferably, be replaced +with the owning plan constructor. `NameScope.Unique` and a previously unseen +`HashedUnique` call now panic after that scope freezes. Use `NameScope.Fork` +only for private render-local helpers; package declarations must be collected +through their `GeneratedPackage`. + +### Protobuf tools + +Every gRPC generation run now requires these exact programs on `PATH`: + +```text +protoc-gen-go v1.36.12 +protoc-gen-go-grpc 1.6.2 +``` + +Install them with: + +```sh +go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.12 +go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 +``` + +Goa resolves each program to an absolute path and verifies its `--version` +output before planning protobuf names. `Meta("protoc:cmd", ...)` may still +select the protobuf compiler or add compiler arguments, but it may not replace +either required Go plugin with `--plugin`. A missing program, another version, +or a plugin override now stops generation before files are written. + +### Regenerated application source + +Regenerate all Goa-owned files together. Do not copy a subset of a new `gen` +tree over an old one: generated declarations and their callers use the same +frozen name records and are not designed to compile across generations. +`goa example` deliberately keeps existing starter files, so separately update +handwritten starter code and any application code that imports generated +transport packages. + +Generated command starters now run an endpoint and write its result instead of +returning `(goa.Endpoint, any, error)` to their caller. The private `doHTTP`, +`doGRPC`, and `doJSONRPC` functions accept a context and output writer, print +unary or streamed values, and return endpoint, stream receive, output, and +connection-close errors. Regenerate or update the whole command directory +together; an existing kept `main.go` cannot call a newly generated private +transport function with the old signature. HTTP and gRPC input-only or +bidirectional stream commands now return a clear unsupported-input error before +parsing an endpoint or opening a connection. + +An HTTP method that defines both an ordinary `Result` and a `StreamingResult` +uses the ordinary result for a normal HTTP response and the stream for its SSE +response. A regenerated example service now returns that ordinary result from +its method and uses the default result view when the service owns view +selection. Existing kept service starters for such a method must be updated; +regenerating `gen` alone does not rewrite them. + +Most generated declarations that have one clear released name keep that name, +including HTTP request and response types, validators, body constructors, +handler constructors, and mount functions. Names still change when preserving +the old spelling would preserve a false conflict or require two declarations +for one generated operation. A service method named `FooEndpoint` becomes +`Foo` when `Foo` no longer exists in that package. When two gRPC methods perform +the same conversion, their two method-based constructors become one constructor +named from the source and target types. Real name collisions receive stable +numeric suffixes instead of suffixes determined by discovery order. These are +Go source breaks for handwritten code that names the affected declarations. +They do not change the wire format by themselves. + +A relocated service type written to a package selected with `struct:pkg:path` +now uses the lowercase final path segment as its Go package clause. For +example, a path ending in `APIKeyService` now declares `package +apikeyservice`. The older mixed-case spelling could differ from the package +name used by the generated import. Handwritten imports that rely on the old +implicit qualifier must use the new lowercase name or add an explicit import +alias. Generated imports already carry the planned qualifier, and the file +path and exported type names do not change. + +Union declarations requested in another generated package now live in that +package's `unions.go` file. Their Go import path and declaration names remain +the same unless a real package collision requires a suffix. Plugins and scripts +that select generated files by filename must stop assuming a union is written +beside the service that first used it. + +Generated interceptor implementations must also change their method +signatures. An argument such as `*LoggingInfo` is now the read-only +`LoggingInfo` interface. Goa supplies a private implementation for the exact +service method and for an endpoint call, stream send, or stream receive. +Handwritten interceptors should accept the interface and continue calling its +accessor methods. These known values are no longer stored in a public struct at +runtime. + +A handwritten multipart request decoder now fills the generated HTTP request +body instead of the service payload. For example, +`func(*multipart.Reader, **service.UploadPayload) error` becomes +`func(*multipart.Reader, *UploadRequestBody) error`. Goa validates that body +and then builds the service payload, just as it does for JSON requests. Array +and map bodies use pointers to the complete generated body value. Regenerate +first, then update each handwritten decoder to its new generated parameter +type. The multipart bytes on the network do not change. + +Some exported helpers disappear when Goa can prove that they do no work. For +example, Goa no longer writes an empty `ValidateUserTypeView` function. Code +that called an empty generated validator must remove that call. JSON-RPC +server-stream methods no longer receive a unary `DecodeResponse` +function because their generated endpoint returns a client stream and never +calls that decoder. Generated gRPC conversion constructors may also be renamed +or combined when several methods perform the same conversion. Handwritten code +should normally enter through the generated client, server, or endpoint +constructors instead of calling transport conversion helpers directly. + +Generated gRPC response encoders with mapped metadata now read the actual +result variable and use code specialized for the metadata type. Some older +combinations generated references to nonexistent `res` or `p` variables and +did not compile. Scalar text remains equivalent, but bytes now use their string +contents instead of Go's slice display: `[]byte{65, 66}` changes from +`"[65 66]"` to `"AB"`. Floating-point values use the exact width selected by +the design. A metadata consumer that compared the old text must update. For a +fixed-view result, generated gRPC encoders and decoders use the view selected in +the design instead of trusting a runtime `goa-view` metadata value. Valid peers +already send the designed view and keep the same result body. + +For caller-selected gRPC result views, Goa now generates one protobuf +conversion for each view. The server writes only the fields in the selected +view, and the client uses the matching constructor instead of applying the +default-view constructor to every response. A dynamic gRPC server stream sends +the selected view in its initial `goa-view` metadata before its first message; +the generated client reads that value before decoding the first message. The +protobuf schema does not change. Regenerate both sides of a dynamic viewed +stream together because an older server does not send this metadata and an +older client always decodes the default view. Regenerate both sides of any +viewed method whose selected view omits default-view fields: older generated +conversions can read an omitted field and fail. Fixed-view methods otherwise +keep their existing view choice. + +One generated command-line flag changes only for a design that used `domain` +for both a server variable and a URL variable. The URL variable is now +`-url-domain`; the old starter registered `-domain` twice and did not run. + +Generated examples also change because each example now uses the exact design +declaration that owns it rather than a value consumed earlier in a shared +random stream. Design-authored example inputs are unchanged, but generated +OpenAPI documents, CLI examples, array lengths, and decoding-error examples may +change because Goa now uses the authored value for that declaration. + +OpenAPI 3 documents may place generated examples under `examples.default` +instead of the single `example` field. OpenAPI 3.2 server-sent-event schemas +mark the event data field as required only when the selected stream field is +required. Snapshot consumers should regenerate and review the documents; the +service's accepted requests and returned results do not change from this +documentation-only difference. + +OpenAPI server variables now include the descriptions written in the design. +Reusable viewed-result schemas use the designed result type name in their +description instead of the generated HTTP response-body type name. These are +documentation text changes only. + +When examples are disabled, OpenAPI generation now omits examples written in +the design as well as examples computed by Goa. Security definitions used only +by excluded services or methods are also omitted. Each server variable now +uses only its own allowed values; an older document could accidentally copy +allowed values from the preceding variable. These changes affect generated +documents, not the service wire format. + +When several methods use one error type, the shared OpenAPI schema now keeps +the reusable type's description. Each OpenAPI 3 operation keeps the description +written for that method's error response. This changes generated API +documentation only; it does not change response data or status codes. + +A viewed-result constructor now returns a nonnil value carrying an unknown +requested view. Generated boundary validation can therefore report the precise +invalid view instead of receiving nil or panicking first. Valid view values and +their projected bodies are unchanged. + +Generated gRPC starters now print the designed method names directly instead of +asking the running gRPC server which methods it registered. Goa-designed +methods keep correct startup logs. A plugin that registers additional gRPC +methods at runtime must print its own log lines; those methods are not part of +Goa's generated service plan. + +A normalized output-path collision, conflicting same-file package or import, +conflicting keep-existing-file setting, or path that is absolute, escapes the +generation root, contains a backslash, or differs only by filesystem case now +fails during planning. Previously, one file could overwrite another or the +merge could produce invalid Go. Same-path contributions now keep every body +section even when two sections have the same diagnostic label, and every file +finalizer runs in contributor order. A plugin that relied on a duplicate label +to suppress output or on only the first finalizer running must remove that +assumption. + +### Wire and runtime compatibility + +| Change | Mixed old and new programs | Rollback effect | +| --- | --- | --- | +| Required Goa `OneOf` validation | Generated service validators and HTTP and gRPC boundaries now reject a union with no selected branch. They also reject a selected message, bytes, or `Any` wrapper whose branch value is nil. The protobuf encoding is unchanged. Valid branches, including a selected empty message, work across versions. | Rolling back re-allows invalid union values; it requires no data migration. | +| `ArrayOfRequired` for primitive values and primitive aliases in JSON bodies | Valid JSON is unchanged. Incoming JSON representations use pointer elements: server request bodies and client response bodies use values such as `[]*string`, then convert them to service value slices such as `[]string`. Outgoing client request bodies and server response bodies remain value slices. Incoming `[null]` is now rejected. Handwritten code that constructs incoming transport body values must supply pointers. gRPC repeated scalar fields and service arrays remain value slices. | Rolling back accepts `null` again; it requires no data migration. | +| Exclusive maximum validation | Generated primitive validators now reject values equal to or above an exclusive maximum. Older validators accidentally repeated the exclusive minimum check and could accept those values. | Rolling back accepts values that violate the designed maximum; it requires no data migration. | +| Caller-selected JSON-RPC result views | A successful response is `{ "jsonrpc": "2.0", "id": ..., "result": { "view": "detailed", "body": ... } }`. The envelope is the method value inside JSON-RPC's standard `result` member. It is generated only when the caller chooses among views. The `body` member is omitted when every selected field is carried in HTTP headers or cookies. The old result was the projected body alone; unary HTTP responses carried the view in the `goa-view` header, while stream messages had no reliable place for it. An old client and new server, or a new client and old server, are not compatible for these methods. Results without views and results whose view is fixed in the design keep their old body shape. The envelope is used consistently for unary and server-sent-event results. A configured response decoder now receives an HTTP response with status 200 instead of the previous zero status. | Deploy or roll back every client and server for a caller-selected view together. There is no dual decoder. A custom response decoder that inspected the synthetic status must accept 200. This generic Goa envelope is valid JSON-RPC, but a method that belongs to another protocol layered on JSON-RPC must still use that protocol's required result schema. | +| HTTP server-sent events | Event-write and flush failures are now returned instead of ignored, and clients decode retry values into the exact optional integer type. Primitive and primitive-alias data use raw SSE text; an optional nil primitive omits the data line, while a present empty string writes an empty data line. The old optional-pointer server accidentally wrote JSON strings or `null`, so a new client reads the JSON quotes or `null` as part of the raw value; a new server remains readable by the old raw-text client. OpenAPI 3.2 now marks optional mapped data as optional and describes primitive data as raw text rather than JSON. For viewed results, the server writes one `goa-view` header, chooses the default when empty, rejects an unknown view before writing, and rejects changing the view after the first event. | Regenerate both sides for a stream with optional primitive data. Regenerate an OpenAPI 3.2 document if tooling relies on the event item schema. Other non-viewed event data keeps its shape, but code may now observe write errors and retry values. Do not mix versions for a variable-view stream. Invalid or partly written streams now fail instead of being accepted or followed by a second HTTP error response. | +| Viewed HTTP streams | HTTP SSE and WebSocket servers now reject an unknown requested view before encoding an event instead of writing a nil or `null` body. Valid fixed and selected views keep their existing body shape. | A new server can reject an invalid view that an old server accepted. No valid request needs a coordinated rollout. | +| JSON-RPC server-sent-event lifecycle | Each server `Send` accepts the streaming result directly and writes one JSON-RPC notification. When the service method returns, the transport writes one terminal response for a request with an ID: `result: null` for success or a JSON-RPC error for a returned error. A request without an ID receives no terminal response. Client `Recv(ctx)` becomes `Recv()` plus `RecvWithContext(ctx)`. Stream constructors return an interface that also implements the service client stream. After notifications, the client returns `io.EOF` or the terminal error. The client now rejects an unknown server-sent-event name or a notification for another JSON-RPC method instead of silently skipping it. Body read and close failures are returned instead of discarded. Unary request reads and batch response delimiter writes now report failures through the server error handler. An unknown JSON-RPC error code now includes the received `error.data` in its invalid-response error. | Generated service implementations must use the standard typed `Send`, `SendWithContext`, and `Close` methods. Client callers must use the new receive methods or service stream interface. The old JSON-RPC-only `StreamEvent`, `SendAndClose`, `SendError`, request ID, marker event, concrete client stream, and WebSocket service APIs are removed. Regenerate clients and servers together. Custom peers must stop sending unknown event names or notifications for another method. Valid Goa peers already satisfy this rule. | +| JSON-RPC request and response rules | A structurally valid request without `id` remains a notification and receives no response. An invalid request object receives Invalid Request with `id: null`, including over server-sent events. A present empty-string or null `id` receives a response with that exact value. A success with no value includes `"result": null`. Leading JSON whitespace no longer changes an array into a single request, `[]` returns one Invalid Request response, and valid and invalid batch members are handled independently. A batch cannot start a server stream: a streaming call with an ID receives Method Not Found with “Method is not available in a batch request,” and its notification form is ignored. In a server that has both ordinary and streaming methods, no `Accept` header or `*/*` permits each method's designed response format. A unary method requires an acceptable JSON media type, a streaming method requires an acceptable server-sent-event media type, and an unacceptable format returns HTTP 406. Media type spelling is case-insensitive and `q=0` rejects that format. Invalid method arguments map to Invalid Params; an undeclared service failure maps to Internal Error. | Valid requests with ordinary IDs keep the same response. Send every streaming call separately as a server-sent-event request instead of including it in a batch. Clients that incorrectly treated an empty or null ID as a notification will now receive a response. Clients that expected no error for an invalid object, one Parse Error for a mixed batch, an omitted `result`, an empty body for `[]`, or selection of a format the method cannot return must follow JSON-RPC 2.0 and HTTP Accept rules. A custom caller of `RawRequest.UnmarshalJSON` must inspect `Invalid`: structurally invalid JSON-RPC objects and IDs other than strings, numbers, or null now set that field without returning a Go JSON decoding error. No data migration is needed. | +| HTTP and JSON-RPC response bodies | Generated clients close response bodies they fully consume and return read and close failures as decoding errors. When decoding and closing both fail, the returned error preserves both. A successful method that deliberately returns the raw body leaves it open for the caller. | Successful decoded responses are unchanged. Failure handling may now return a nonnil error, or `decoding_error` instead of a raw or request error, where older code discarded or mislabeled a read or close failure. | +| Nested HTTP validation paths | Generated validation errors now keep the complete field and array-index path while entering nested generated types instead of restarting the path at the nested value. | Invalid inputs may produce more precise error field names. Valid inputs and wire data are unchanged. | +| HTTP float query text | Generated clients now use Go's shortest round-trip text for float32 and float64 query values. Ordinary values are unchanged, while very large or small values may use exponent form, such as `1e+100`, instead of a long decimal expansion. Servers decode the same number. | Systems that sign, cache, or compare the exact URL text must accept the compact form. Rolling back returns to the longer spelling. | +| gRPC metadata text | Generated metadata conversions are specialized for the designed type. Bytes now use their string contents, so `[]byte{65, 66}` is sent as `"AB"` instead of `"[65 66]"`. Floating-point values use the designed width. Other scalar text remains equivalent. | Regenerate both sides when a metadata consumer parses the old byte-slice display or depends on the old floating-point spelling. Rolling back restores the old text. | +| gRPC result views | Unary and streaming clients and servers now use the protobuf conversion for the selected view. Dynamic server streams send the view in initial `goa-view` metadata, and dynamic clients require that metadata before decoding the first message. The protobuf schema is unchanged. An old stream server does not send this value, while an old stream client assumes the default view. Older conversions can also fail when a selected view omits a field used by the default conversion. | Regenerate and deploy both sides of a dynamic viewed gRPC stream together. Also regenerate both sides of any viewed method whose selected view omits default-view fields. Fixed-view methods otherwise keep their wire choice. | +| Protobuf name collisions | Normal schemas keep their existing field encoding. A design whose protobuf declarations collide may receive stable numeric message, file, or generated Go suffixes instead of invalid source. Descriptor names, and a gRPC method path if its service or method itself needed a suffix, can therefore change for that formerly conflicting design. | Regenerate both sides from the same Goa version. A previously valid, collision-free schema needs no coordinated runtime rollout. | +| Stricter design validation | Generation now rejects duplicate `ConvertTo` or `CreateFrom` mappings for the same Goa and external type, non-primitive gRPC metadata, streaming HTTP success fields mapped to headers or cookies, and inherited HTTP or gRPC error mappings whose concrete method error has a different type, validation, default, or metadata. Error metadata includes whether the error is temporary, a timeout, or a server fault. A service and its methods also cannot reuse one standard error name when those settings or value definitions differ, because Goa emits one shared `Make` constructor. Authored custom error types do not use that constructor and remain independent. gRPC and JSON-RPC reject a method that defines both `Result` and `StreamingResult`, even when both use the same Goa type. An ordinary HTTP method with both results must use `ServerSentEvents()`; the old same-type case could accidentally select WebSocket generation. JSON-RPC also rejects client and bidirectional streams and a server stream that does not call `ServerSentEvents()`. | These checks stop generation; they do not change a compiled program. Fix the design rather than rolling out mixed generated trees. | + +Fresh factories make repeated and concurrent generation independent. The main +operational risks are an uncollected template symbol, an incomplete emitted +identity, or an inaccurate protoc family name. Focused catalog tests, reversed- +order tests, concurrent-run tests, real generated-module compilation, the +supported protoc toolchain, goa-ai generation, and full AURA regeneration are +the required proof. + +## Review gate + +Before changing generation lifecycle, names, roots, transports, plugins, or +file merging, trace one representative declaration through: + +1. the prepared root; +2. the retained service plan; +3. the selected transport or plugin plan; +4. the owning generated package and `NameDeclaration`; +5. stable collection and freeze; +6. every declaration and reference rendered from that record; +7. plugin contributions; and +8. the final merged file and compiled generated module. + +Also prove a valid counterexample at the next wider lifetime: two declarations +with one semantic ID but different origins, one origin with different request +and response wire contracts, two packages with the same basename, repeated +runs in one process, and concurrent runs. A service-only render test or a +source-text assertion is insufficient when the failure can appear in a +transport, protoc-generated family, plugin, or merged output. diff --git a/codegen/cli/cli.go b/codegen/cli/cli.go index f83ef6b082..1b7f65884a 100644 --- a/codegen/cli/cli.go +++ b/codegen/cli/cli.go @@ -19,11 +19,18 @@ import ( type ( // CommandData contains the data needed to render a command. CommandData struct { + // ServiceName is the design service selected by this command. + ServiceName string + // UsageDeclaration is the package function that prints help for this command. + UsageDeclaration *codegen.NameDeclaration // Name of command e.g. "cellar-storage" Name string // VarName is the name of the command variable e.g. // "cellarStorage" VarName string + // FlagSetVar is the exact local variable that stores this command's flags + // in the generated endpoint parser. + FlagSetVar string // Description is the help text. Description string // Subcommands is the list of endpoint commands. @@ -31,8 +38,7 @@ type ( // Example is a valid command invocation, starting with the // command name. Example string - // PkgName is the service HTTP client package import name, - // e.g. "storagec". + // PkgName is the transport client package import name, e.g. "storagec". PkgName string // Interceptors contains the data for client interceptors if any. Interceptors *InterceptorData @@ -40,10 +46,17 @@ type ( // SubcommandData contains the data needed to render a sub-command. SubcommandData struct { + // MethodName is the design method selected by this command. + MethodName string + // UsageDeclaration is the package function that prints help for this subcommand. + UsageDeclaration *codegen.NameDeclaration // Name is the sub-command name e.g. "add" Name string // FullName is the sub-command full name e.g. "storageAdd" FullName string + // FlagSetVar is the exact local variable that stores this method's flags + // in the generated endpoint parser. + FlagSetVar string // Description is the help text. Description string // Flags is the list of flags supported by the subcommand. @@ -53,6 +66,8 @@ type ( // BuildFunction contains the data to generate a payload builder function // if any. Exclusive with Conversion. BuildFunction *BuildFunctionData + // ActualPointerVars lists the exact parser variables passed to BuildFunction. + ActualPointerVars []string // Conversion contains the flag value to payload conversion function if // any. Exclusive with BuildFunction. Conversion string @@ -60,14 +75,23 @@ type ( Example string // Interceptors contains the data for client interceptors if any apply to the endpoint method. Interceptors *InterceptorData + // conversionFlag is the parsed flag converted directly into a primitive payload. + conversionFlag *FlagData } // InterceptorData contains the data needed to generate interceptor code. InterceptorData struct { // VarName is the name of the interceptor variable. VarName string + // ParserVar is the exact parameter name used by the generated endpoint parser. + ParserVar string // PkgName is the package name containing the interceptor type. PkgName string + // ClientInterceptorsDeclaration is the exact client interceptor interface. + ClientInterceptorsDeclaration *codegen.NameDeclaration + // ClientEndpointWrapperDeclaration is the exact wrapper applied to one + // client endpoint. It is nil for service-level command data. + ClientEndpointWrapperDeclaration *codegen.NameDeclaration } // FlagData contains the data needed to render a command-line flag. @@ -80,6 +104,8 @@ type ( Type string // FullName is the flag full name e.g. "storageAddVintage" FullName string + // PointerVar is the exact local variable that points to the parsed flag value. + PointerVar string // Description is the flag help text. Description string // Required is true if the flag is required. @@ -88,6 +114,8 @@ type ( Example string // Default returns the default value if any. Default any + // value describes how the flag text becomes a Go value. + value *flagValuePlan } // BuildFunctionData contains the data needed to generate a constructor @@ -119,6 +147,17 @@ type ( CheckErr bool } + // ParserDeclarations contains every package function written to one command + // parser file. + ParserDeclarations struct { + // ParseEndpoint is the function that selects and builds an endpoint call. + ParseEndpoint *codegen.NameDeclaration + // UsageCommands is the function that lists available commands. + UsageCommands *codegen.NameDeclaration + // UsageExamples is the function that prints example commands. + UsageExamples *codegen.NameDeclaration + } + // FlagArgData describes a payload initialization argument from which a // command-line flag and the code that loads the flag value into the // corresponding payload builder field are generated. @@ -128,6 +167,9 @@ type ( Name string // TypeName is the argument Go type name. TypeName string + // Plan contains the conversion and validation selected by Goa's transport + // generators. Plugins may continue to use TypeName and Validate. + Plan *FlagPlan // TypeRef is the reference to the argument type. TypeRef string // FieldName is the name of the payload field initialized with the @@ -141,13 +183,33 @@ type ( Example any // DefaultValue is the default value of the argument if any. DefaultValue any - // Validate contains the validation code for the argument value if any. + // Validate contains validation code kept for plugins built against the + // released CLI data. It cannot be used with Plan. + // + // Deprecated: Goa transport generators use Plan so checks receive the + // exact parsed value name. Plugins may continue to use Validate. Validate string // OmitField if true generates the flag without a corresponding payload // builder field. OmitField bool } + // FlagPlan contains the conversion and validation choices made by Goa's + // transport generators. + FlagPlan struct { + value *flagValuePlan + validation func(string) string + } + + // flagValuePlan records how command-line text becomes one generated Go value. + flagValuePlan struct { + kind expr.Kind + typeName string + typeRef string + alias bool + protobufMessage bool + } + // FieldData contains the data needed to generate the code that initializes a // field in the method payload type. FieldData struct { @@ -183,6 +245,28 @@ type ( // Args is the list of arguments for the constructor. Args []*codegen.InitArgData } + + // conversionData describes the Go value produced from command-line flag text. + conversionData struct { + code string + value string + declaresError bool + canError bool + } + + // conversionVariableNames contains local names used while parsing one flag. + conversionVariableNames struct { + error string + parsed string + converted string + } + + // parserFlagsData gives the shared flag template its commands and the fixed + // local names chosen for the surrounding endpoint parser. + parserFlagsData struct { + Commands []*CommandData + Variables *ParserVariablesData + } ) // BuildCommandData builds the data needed by CLI code generators to render the @@ -196,13 +280,15 @@ func BuildCommandData(data *service.Data) *CommandData { var interceptors *InterceptorData if len(data.ClientInterceptors) > 0 { interceptors = &InterceptorData{ - VarName: codegen.Goify(data.Name, false) + "Inter", - PkgName: data.PkgName, + VarName: codegen.Goify(data.Name, false) + "Inter", + PkgName: data.PkgName, + ClientInterceptorsDeclaration: data.ClientInterceptorsDeclaration, } } return &CommandData{ - Name: codegen.KebabCase(data.Name), + ServiceName: data.Name, + Name: codegen.KebabCase(data.PathName), VarName: codegen.Goify(data.Name, false), Description: description, PkgName: data.PkgName + "c", @@ -221,53 +307,30 @@ func BuildSubcommandData(data *service.Data, m *service.MethodData, buildFunctio description = fmt.Sprintf("Make request to the %q endpoint", m.Name) } - var conversion string + var conversionFlag *FlagData if m.Payload != "" && buildFunction == nil && len(flags) > 0 { - // No build function, just convert the arg to the body type - var convPre, convSuff string - target := "data" - if flagType(m.Payload) == "JSON" { - target = "val" - convPre = fmt.Sprintf("var val %s\n", m.Payload) - convSuff = "\ndata = val" - } - conv, _, check := conversionCode( - "*"+flags[0].FullName+"Flag", - target, - m.Payload, - false, - ) - conversion = convPre + conv + convSuff - if check { - conversion = "var err error\n" + conversion - conversion += "\nif err != nil {\n" - if flagType(m.Payload) == "JSON" { - conversion += fmt.Sprintf(`return nil, nil, fmt.Errorf("invalid JSON for %s, \nerror: %%s, \nexample of valid JSON:\n%%s", err, %q)`, - flags[0].FullName+"Flag", flags[0].Example) - } else { - conversion += fmt.Sprintf(`return nil, nil, fmt.Errorf("invalid value for %s, must be %s")`, - flags[0].FullName+"Flag", flags[0].Type) - } - conversion += "\n}" - } + conversionFlag = flags[0] } var interceptors *InterceptorData if len(m.ClientInterceptors) > 0 { interceptors = &InterceptorData{ - VarName: codegen.Goify(data.Name, false) + "Inter", - PkgName: data.PkgName, + VarName: codegen.Goify(data.Name, false) + "Inter", + PkgName: data.PkgName, + ClientInterceptorsDeclaration: data.ClientInterceptorsDeclaration, + ClientEndpointWrapperDeclaration: m.ClientEndpointWrapperDeclaration, } } sub := &SubcommandData{ - Name: name, - FullName: fullName, - Description: description, - Flags: flags, - MethodVarName: m.VarName, - BuildFunction: buildFunction, - Conversion: conversion, - Interceptors: interceptors, + MethodName: m.Name, + Name: name, + FullName: fullName, + Description: description, + Flags: flags, + MethodVarName: m.VarName, + BuildFunction: buildFunction, + conversionFlag: conversionFlag, + Interceptors: interceptors, } generateExample(sub, data.Name) @@ -282,12 +345,43 @@ func EndpointParserFile( specs []*codegen.ImportSpec, data []*CommandData, parseSection *codegen.SectionTemplate, +) *codegen.File { + return endpointParserFile(path, title, specs, data, parseSection, releasedUsageCommandsName, releasedUsageExamplesName) +} + +// EndpointParserFile returns a parser file that uses the function names chosen +// for this parser plan. +func (p *ParserPlan) EndpointParserFile( + path, title string, + specs []*codegen.ImportSpec, + data []*CommandData, + parseSection *codegen.SectionTemplate, +) *codegen.File { + return endpointParserFile( + path, + title, + specs, + data, + parseSection, + p.Declarations.UsageCommands.Name, + p.Declarations.UsageExamples.Name, + ) +} + +// endpointParserFile assembles one parser file with the supplied help function +// names. +func endpointParserFile( + path, title string, + specs []*codegen.ImportSpec, + data []*CommandData, + parseSection *codegen.SectionTemplate, + usageCommandsName, usageExamplesName func() string, ) *codegen.File { sections := make([]*codegen.SectionTemplate, 0, 4+len(data)) sections = append(sections, codegen.Header(title, "cli", specs), - UsageCommands(data), - UsageExamples(data), + usageCommands(data, usageCommandsName), + usageExamples(data, usageExamplesName), parseSection, ) for _, cmd := range data { @@ -316,20 +410,33 @@ func MakeFlags( check bool ) for i, arg := range args { - f := NewFlagData(svcn, m.Name, arg.Name, arg.TypeName, arg.Description, arg.Required, arg.Example, arg.DefaultValue) + value := (*flagValuePlan)(nil) + validation := func(string) string { + return arg.Validate + } + if arg.Plan == nil { + value = legacyFlagValuePlan(arg.TypeName) + } else { + if arg.Validate != "" { + panic("CLI flag validation cannot use both Validate and Plan") + } + value = arg.Plan.value + validation = arg.Plan.validation + } + f := newFlagData(svcn, m.Name, arg.Name, value, arg.Description, arg.Required, arg.Example, arg.DefaultValue) flags[i] = f params[i] = f.FullName if arg.OmitField { continue } - code, chek := FieldLoadCode(f, arg.Name, arg.TypeName, arg.Validate, arg.DefaultValue, payload, payloadRef) + code, chek := fieldLoadCode(f, arg.Name, value, validation, arg.DefaultValue, payload, payloadRef) check = check || chek tn := arg.TypeRef - if f.Type == "JSON" { + if value.isJSON() { // We need to declare the variable without // a pointer to be able to unmarshal the JSON // using its address. - tn = arg.TypeName + tn = value.typeName } fdata = append(fdata, &FieldData{ Name: arg.Name, @@ -369,6 +476,12 @@ func PayloadBuildersFile(path, title string, specs []*codegen.ImportSpec, data * // UsageCommands builds a section template that generates a help text showing // the list of allowed commands and sub-commands. func UsageCommands(data []*CommandData) *codegen.SectionTemplate { + return usageCommands(data, releasedUsageCommandsName) +} + +// usageCommands renders command help with the function name chosen for its +// generated package. +func usageCommands(data []*CommandData, name func() string) *codegen.SectionTemplate { usages := make([]string, len(data)) for i, cmd := range data { subs := make([]string, len(cmd.Subcommands)) @@ -383,12 +496,24 @@ func UsageCommands(data []*CommandData) *codegen.SectionTemplate { usages[i] = fmt.Sprintf("%s %s%s%s", cmd.Name, lp, strings.Join(subs, "|"), rp) } - return &codegen.SectionTemplate{Source: cliTemplates.Read(usageCommandsT), Data: usages} + return &codegen.SectionTemplate{ + Source: cliTemplates.Read(usageCommandsT), + Data: usages, + FuncMap: map[string]any{ + "usageName": name, + }, + } } // UsageExamples builds a section template that generates a help text showing // a valid invocation of the CLI tool. func UsageExamples(data []*CommandData) *codegen.SectionTemplate { + return usageExamples(data, releasedUsageExamplesName) +} + +// usageExamples renders example help with the function name chosen for its +// generated package. +func usageExamples(data []*CommandData, name func() string) *codegen.SectionTemplate { var examples []string for i, cmd := range data { if i < 5 { @@ -396,7 +521,25 @@ func UsageExamples(data []*CommandData) *codegen.SectionTemplate { } } - return &codegen.SectionTemplate{Source: cliTemplates.Read(usageExamplesT), Data: examples} + return &codegen.SectionTemplate{ + Source: cliTemplates.Read(usageExamplesT), + Data: examples, + FuncMap: map[string]any{ + "usageName": name, + }, + } +} + +// releasedUsageCommandsName returns the help function name used by the +// released parser helper. +func releasedUsageCommandsName() string { + return "UsageCommands" +} + +// releasedUsageExamplesName returns the example function name used by the +// released parser helper. +func releasedUsageExamplesName() string { + return "UsageExamples" } // FlagsCode returns a string containing the code that parses the command-line @@ -419,6 +562,28 @@ func FlagsCode(data []*CommandData) string { return flagsCode.String() } +// FlagsCode renders flag parsing with the exact local names chosen by this +// parser plan. +func (p *ParserPlan) FlagsCode(data []*CommandData) string { + if p.Variables == nil { + panic("CLI parser variables must be planned before rendering flags") + } + section := codegen.SectionTemplate{ + Name: "parse-endpoint-flags", + Source: cliTemplates.Read(parseFlagsPlannedT), + Data: &parserFlagsData{ + Commands: data, + Variables: p.Variables, + }, + FuncMap: map[string]any{"printDescription": printDescription}, + } + var flagsCode bytes.Buffer + if err := section.Write(&flagsCode); err != nil { + panic(err) + } + return flagsCode.String() +} + // CommandUsage builds the section templates that can be used to generate the // endpoint command usage code. func CommandUsage(data *CommandData) *codegen.SectionTemplate { @@ -443,7 +608,44 @@ func PayloadBuilderSection(buildFunction *BuildFunctionData) *codegen.SectionTem } } -// NewFlagData creates a new FlagData from the given argument attributes. +// NewFlagPlan records the conversion and validation selected for one command- +// line flag. typeName is the concrete local type used for JSON values. typeRef +// is the concrete non-pointer reference used for primitive casts. +func NewFlagPlan(attribute *expr.AttributeExpr, typeName, typeRef string, validation func(string) string) *FlagPlan { + kind := expr.AnyKind + alias := expr.IsAlias(attribute.Type) + if custom, _ := codegen.GetMetaType(attribute); custom == "" && expr.IsPrimitive(attribute.Type) { + dataType := attribute.Type + for { + userType, ok := dataType.(expr.UserType) + if !ok { + break + } + dataType = userType.Attribute().Type + } + kind = dataType.Kind() + } + return &FlagPlan{ + value: &flagValuePlan{ + kind: kind, + typeName: typeName, + typeRef: typeRef, + alias: alias, + }, + validation: validation, + } +} + +// NewProtobufFlagPlan records a command-line flag whose JSON value is a +// protobuf message. The generated code uses protobuf's JSON decoder so the +// accepted field names and values match the message contract. +func NewProtobufFlagPlan(attribute *expr.AttributeExpr, typeName string) *FlagPlan { + plan := NewFlagPlan(attribute, typeName, typeName, nil) + plan.value.protobufMessage = true + return plan +} + +// NewFlagData creates flag data from the released string type description. // // svcn is the service name // en is the endpoint name @@ -453,105 +655,143 @@ func PayloadBuilderSection(buildFunction *BuildFunctionData) *codegen.SectionTem // required determines if the flag is required // example is an example value for the flag func NewFlagData(svcn, en, name, typeName, description string, required bool, example, def any) *FlagData { + return newFlagData(svcn, en, name, legacyFlagValuePlan(typeName), description, required, example, def) +} + +// NewFlagDataForPlan creates flag data from the given conversion and +// validation choices. +func NewFlagDataForPlan(svcn, en, name string, plan *FlagPlan, description string, required bool, example, def any) *FlagData { + return newFlagData(svcn, en, name, plan.value, description, required, example, def) +} + +// FieldLoadCode returns the code used in the build payload function that +// initializes one of the payload object fields. It returns the initialization +// code and a boolean indicating whether the code requires an "err" variable. +func FieldLoadCode(f *FlagData, argName, argTypeName, validate string, defaultValue any, payload expr.DataType, payloadRef string) (string, bool) { + var validation func(string) string + if validate != "" { + validation = func(string) string { + return validate + } + } + return fieldLoadCode(f, argName, legacyFlagValuePlan(argTypeName), validation, defaultValue, payload, payloadRef) +} + +// newFlagData creates flag data from the conversion selected during planning. +func newFlagData(svcn, en, name string, value *flagValuePlan, description string, required bool, example, def any) *FlagData { ex := jsonExample(example) fn := goifyTerms(svcn, en, name) return &FlagData{ Name: codegen.KebabCase(name), VarName: codegen.Goify(name, false), - Type: flagType(typeName), + Type: value.flagType(), FullName: fn, Description: description, Required: required, Example: ex, Default: def, + value: value, } } -// FieldLoadCode returns the code used in the build payload function that -// initializes one of the payload object fields. It returns the initialization -// code and a boolean indicating whether the code requires an "err" variable. -func FieldLoadCode(f *FlagData, argName, argTypeName, validate string, defaultValue any, payload expr.DataType, payloadRef string) (string, bool) { +// legacyFlagValuePlan reproduces the released string-based flag conversion. +func legacyFlagValuePlan(typeName string) *flagValuePlan { + kind := expr.AnyKind + switch typeName { + case codegen.GoNativeTypeName(expr.Boolean): + kind = expr.BooleanKind + case codegen.GoNativeTypeName(expr.Int): + kind = expr.IntKind + case codegen.GoNativeTypeName(expr.Int32): + kind = expr.Int32Kind + case codegen.GoNativeTypeName(expr.Int64): + kind = expr.Int64Kind + case codegen.GoNativeTypeName(expr.UInt): + kind = expr.UIntKind + case codegen.GoNativeTypeName(expr.UInt32): + kind = expr.UInt32Kind + case codegen.GoNativeTypeName(expr.UInt64): + kind = expr.UInt64Kind + case codegen.GoNativeTypeName(expr.Float32): + kind = expr.Float32Kind + case codegen.GoNativeTypeName(expr.Float64): + kind = expr.Float64Kind + case codegen.GoNativeTypeName(expr.String): + kind = expr.StringKind + case codegen.GoNativeTypeName(expr.Bytes): + kind = expr.BytesKind + } + return &flagValuePlan{ + kind: kind, + typeName: typeName, + typeRef: typeName, + } +} + +// fieldLoadCode writes a field conversion from its complete generation plan. +func fieldLoadCode( + f *FlagData, + argName string, + value *flagValuePlan, + validation func(string) string, + defaultValue any, + payload expr.DataType, + payloadRef string, +) (string, bool) { var ( - code string - declErr bool - startIf string - endIf string + code string + validationTarget string + declErr bool + startIf string + endIf string ) if !f.Required { startIf = fmt.Sprintf("if %s != \"\" {\n", f.FullName) endIf = "\n}" } - if argTypeName == codegen.GoNativeTypeName(expr.String) { - ref := "&" - if f.Required || defaultValue != nil { - ref = "" + pointer := value.kind != expr.BytesKind && !value.isJSON() && !f.Required && defaultValue == nil + conversion := conversionCode(f.FullName, argName, value, pointer, conversionVariableNames{ + error: "err", + parsed: "v", + converted: "val", + }) + code = conversion.code + validationTarget = conversion.value + declErr = conversion.declaresError + if conversion.canError { + code += "\nif err != nil {\n" + nilVal := "nil" + if expr.IsPrimitive(payload) { + code += fmt.Sprintf("var zero %s\n", payloadRef) + nilVal = "zero" } - code = argName + " = " + ref + f.FullName - declErr = validate != "" - } else { - var checkErr bool - code, declErr, checkErr = conversionCode(f.FullName, argName, argTypeName, !f.Required && defaultValue == nil) - if checkErr { - code += "\nif err != nil {\n" + if value.isJSON() { + code += fmt.Sprintf(`return %s, fmt.Errorf("invalid JSON for %s, \nerror: %%s, \nexample of valid JSON:\n%%s", err, %q)`, + nilVal, argName, f.Example) + } else { + code += fmt.Sprintf(`return %s, fmt.Errorf("invalid value for %s, must be %s")`, + nilVal, argName, f.Type) + } + code += "\n}" + } + if validation != nil { + validate := validation(validationTarget) + if validate != "" { + declErr = true + code += "\n" + validate + "\n" nilVal := "nil" if expr.IsPrimitive(payload) { code += fmt.Sprintf("var zero %s\n", payloadRef) nilVal = "zero" } - if flagType(argTypeName) == "JSON" { - code += fmt.Sprintf(`return %s, fmt.Errorf("invalid JSON for %s, \nerror: %%s, \nexample of valid JSON:\n%%s", err, %q)`, - nilVal, argName, f.Example) - } else { - code += fmt.Sprintf(`return %s, fmt.Errorf("invalid value for %s, must be %s")`, - nilVal, argName, f.Type) - } - code += "\n}" - } - } - if validate != "" { - nilCheck := "if " + argName + " != nil {" - if strings.HasPrefix(validate, nilCheck) { - // hackety hack... the validation code is generated for the client and needs to - // account for the fact that the field could be nil in this case. We are reusing - // that code to validate a CLI flag which can never be nil. Lint tools complain - // about that so remove the if statements. Ideally we'd have a better way to do - // this but that requires a lot of changes and the added complexity might not be - // worth it. - var lines []string - ls := strings.Split(validate, "\n") - for i := 1; i < len(ls)-1; i++ { - if ls[i+1] == nilCheck { - i++ // skip both closing brace on previous line and check - continue - } - lines = append(lines, ls[i]) - } - validate = strings.Join(lines, "\n") + code += fmt.Sprintf("if err != nil {\n\treturn %s, err\n}", nilVal) } - code += "\n" + validate + "\n" - nilVal := "nil" - if expr.IsPrimitive(payload) { - code += fmt.Sprintf("var zero %s\n", payloadRef) - nilVal = "zero" - } - code += fmt.Sprintf("if err != nil {\n\treturn %s, err\n}", nilVal) } return fmt.Sprintf("%s%s%s", startIf, code, endIf), declErr } -// flagType calculates the type of a flag -func flagType(tname string) string { - switch tname { - case boolN, intN, int32N, int64N, uintN, uint32N, uint64N, float32N, float64N, stringN: - return strings.ToUpper(tname) - case bytesN: - return "STRING" - default: // Any, Array, Map, Object, User - return "JSON" - } -} - -// jsonExample generates a json example +// jsonExample turns a generated value into the JSON text shown in CLI help and +// invalid-value errors. func jsonExample(v any) string { // In JSON, keys must be a string. But goa allows map keys to be anything. r := reflect.ValueOf(v) @@ -561,21 +801,17 @@ func jsonExample(v any) string { a := make(map[string]any, len(keys)) var kstr string for _, k := range keys { - switch t := k.Interface().(type) { - case bool: - kstr = strconv.FormatBool(t) - case int32: - kstr = strconv.FormatInt(int64(t), 10) - case int64: - kstr = strconv.FormatInt(t, 10) - case int: - kstr = strconv.Itoa(t) - case float32: - kstr = strconv.FormatFloat(float64(t), 'f', -1, 32) - case float64: - kstr = strconv.FormatFloat(t, 'f', -1, 64) + switch k.Kind() { + case reflect.Bool: + kstr = strconv.FormatBool(k.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + kstr = strconv.FormatInt(k.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + kstr = strconv.FormatUint(k.Uint(), 10) + case reflect.Float32, reflect.Float64: + kstr = strconv.FormatFloat(k.Float(), 'f', -1, k.Type().Bits()) default: - kstr = k.String() + panic(fmt.Sprintf("unsupported CLI example map key kind %s", k.Kind())) } a[kstr] = r.MapIndex(k).Interface() } @@ -593,96 +829,175 @@ func jsonExample(v any) string { return ex } -var ( - boolN = codegen.GoNativeTypeName(expr.Boolean) - intN = codegen.GoNativeTypeName(expr.Int) - int32N = codegen.GoNativeTypeName(expr.Int32) - int64N = codegen.GoNativeTypeName(expr.Int64) - uintN = codegen.GoNativeTypeName(expr.UInt) - uint32N = codegen.GoNativeTypeName(expr.UInt32) - uint64N = codegen.GoNativeTypeName(expr.UInt64) - float32N = codegen.GoNativeTypeName(expr.Float32) - float64N = codegen.GoNativeTypeName(expr.Float64) - stringN = codegen.GoNativeTypeName(expr.String) - bytesN = codegen.GoNativeTypeName(expr.Bytes) -) - -// conversionCode produces the code that converts the string contained in the -// variable named from to the value stored in the variable "to" of type -// typeName. The second return value indicates whether the "err" variable must -// be declared prior to the conversion code being rendered. The last return -// value indicates whether the generated code can produce errors (i.e. -// initialize the err variable). -func conversionCode(from, to, typeName string, pointer bool) (string, bool, bool) { - var ( - parse string - cast string - - target = to - needCast = typeName != stringN && typeName != bytesN && flagType(typeName) != "JSON" - declErr = true - checkErr = true - decl = "" - ) - if needCast && pointer { - target = "val" - decl = ":" +// directPayloadConversion writes the primitive payload conversion after the +// parser has chosen the exact flag pointer variable used by this method. +func directPayloadConversion(flag *FlagData, variables *ParserVariablesData) string { + var prefix, suffix string + target := variables.Data + if flag.value.isJSON() { + target = variables.ConvertedValue + prefix = fmt.Sprintf("var %s %s\n", variables.ConvertedValue, flag.value.typeName) + suffix = fmt.Sprintf("\n%s = %s", variables.Data, variables.ConvertedValue) } - switch typeName { - case boolN: - if pointer { - parse = fmt.Sprintf("var %s bool\n", target) + converted := conversionCode("*"+flag.PointerVar, target, flag.value, false, conversionVariableNames{ + error: variables.Error, + parsed: variables.ParsedValue, + converted: variables.ConvertedValue, + }) + code := prefix + converted.code + suffix + if !converted.canError { + return code + } + code = fmt.Sprintf("var %s error\n%s\nif %s != nil {\n", variables.Error, code, variables.Error) + if flag.value.isJSON() { + code += fmt.Sprintf(`return nil, nil, fmt.Errorf("invalid JSON for %s, \nerror: %%s, \nexample of valid JSON:\n%%s", %s, %q)`, + flag.PointerVar, variables.Error, flag.Example) + } else { + code += fmt.Sprintf(`return nil, nil, fmt.Errorf("invalid value for %s, must be %s")`, + flag.PointerVar, flag.Type) + } + return code + "\n}" +} + +// conversionCode describes the code and concrete Go value produced from the +// flag text in from. The result also reports how the conversion uses err. +func conversionCode(from, to string, value *flagValuePlan, pointer bool, variables conversionVariableNames) conversionData { + var parse string + switch value.kind { + case expr.BooleanKind: + if !value.alias { + return directParsedConversion(to, value.typeRef, fmt.Sprintf("strconv.ParseBool(%s)", from), pointer, variables) } - parse += fmt.Sprintf("%s, err = strconv.ParseBool(%s)", target, from) - case intN: - parse = fmt.Sprintf("var v int64\nv, err = strconv.ParseInt(%s, 10, strconv.IntSize)", from) - cast = fmt.Sprintf("%s %s= int(v)", target, decl) - case int32N: - parse = fmt.Sprintf("var v int64\nv, err = strconv.ParseInt(%s, 10, 32)", from) - cast = fmt.Sprintf("%s %s= int32(v)", target, decl) - case int64N: - parse = fmt.Sprintf("%s, err %s= strconv.ParseInt(%s, 10, 64)", target, decl, from) - declErr = decl == "" - case uintN: - parse = fmt.Sprintf("var v uint64\nv, err = strconv.ParseUint(%s, 10, strconv.IntSize)", from) - cast = fmt.Sprintf("%s %s= uint(v)", target, decl) - case uint32N: - parse = fmt.Sprintf("var v uint64\nv, err = strconv.ParseUint(%s, 10, 32)", from) - cast = fmt.Sprintf("%s %s= uint32(v)", target, decl) - case uint64N: - parse = fmt.Sprintf("%s, err %s= strconv.ParseUint(%s, 10, 64)", target, decl, from) - declErr = decl == "" - case float32N: - parse = fmt.Sprintf("var v float64\nv, err = strconv.ParseFloat(%s, 32)", from) - cast = fmt.Sprintf("%s %s= float32(v)", target, decl) - case float64N: - parse = fmt.Sprintf("%s, err %s= strconv.ParseFloat(%s, 64)", target, decl, from) - declErr = decl == "" - case stringN: - parse = fmt.Sprintf("%s %s= %s", target, decl, from) - declErr = false - checkErr = false - case bytesN: - parse = fmt.Sprintf("%s %s= []byte(%s)", target, decl, from) - declErr = false - checkErr = false + parse = fmt.Sprintf("var %s bool\n%s, %s = strconv.ParseBool(%s)", variables.parsed, variables.parsed, variables.error, from) + case expr.IntKind, expr.Int32Kind, expr.Int64Kind: + bits := "64" + if value.kind == expr.IntKind { + bits = "strconv.IntSize" + } else if value.kind == expr.Int32Kind { + bits = "32" + } + if value.kind == expr.Int64Kind && !value.alias { + return directParsedConversion(to, value.typeRef, fmt.Sprintf("strconv.ParseInt(%s, 10, 64)", from), pointer, variables) + } + parse = fmt.Sprintf("var %s int64\n%s, %s = strconv.ParseInt(%s, 10, %s)", variables.parsed, variables.parsed, variables.error, from, bits) + case expr.UIntKind, expr.UInt32Kind, expr.UInt64Kind: + bits := "64" + if value.kind == expr.UIntKind { + bits = "strconv.IntSize" + } else if value.kind == expr.UInt32Kind { + bits = "32" + } + if value.kind == expr.UInt64Kind && !value.alias { + return directParsedConversion(to, value.typeRef, fmt.Sprintf("strconv.ParseUint(%s, 10, 64)", from), pointer, variables) + } + parse = fmt.Sprintf("var %s uint64\n%s, %s = strconv.ParseUint(%s, 10, %s)", variables.parsed, variables.parsed, variables.error, from, bits) + case expr.Float32Kind, expr.Float64Kind: + bits := "64" + if value.kind == expr.Float32Kind { + bits = "32" + } + if value.kind == expr.Float64Kind && !value.alias { + return directParsedConversion(to, value.typeRef, fmt.Sprintf("strconv.ParseFloat(%s, 64)", from), pointer, variables) + } + parse = fmt.Sprintf("var %s float64\n%s, %s = strconv.ParseFloat(%s, %s)", variables.parsed, variables.parsed, variables.error, from, bits) + case expr.StringKind: + converted := from + if value.alias { + converted = fmt.Sprintf("%s(%s)", value.typeRef, from) + } + if pointer && !value.alias { + return conversionData{code: fmt.Sprintf("%s = &%s", to, from), value: from} + } + code, target := assignConvertedValue(to, converted, pointer, variables.converted) + return conversionData{code: code, value: target} + case expr.BytesKind: + converted := fmt.Sprintf("[]byte(%s)", from) + if value.alias { + converted = fmt.Sprintf("%s(%s)", value.typeRef, from) + } + return conversionData{code: fmt.Sprintf("%s = %s", to, converted), value: to} default: - parse = fmt.Sprintf("err = json.Unmarshal([]byte(%s), &%s)", from, target) - } - if !needCast { - return parse, declErr, checkErr + if value.protobufMessage { + parse = fmt.Sprintf("%s = protojson.Unmarshal([]byte(%s), &%s)", variables.error, from, to) + } else { + parse = fmt.Sprintf("%s = json.Unmarshal([]byte(%s), &%s)", variables.error, from, to) + } + return conversionData{code: parse, value: to, declaresError: true, canError: true} } - if cast != "" { - parse = parse + "\n" + cast + converted := fmt.Sprintf("%s(%s)", value.typeRef, variables.parsed) + assignment, target := assignConvertedValue(to, converted, pointer, variables.converted) + return conversionData{ + code: parse + "\n" + assignment, + value: target, + declaresError: true, + canError: true, } - if to != target { - ref := "" - if pointer { - ref = "&" +} + +// directParsedConversion writes a parser result directly into its final value +// when the parser already returns the generated Go type. +func directParsedConversion(target, typeRef, parser string, pointer bool, variables conversionVariableNames) conversionData { + if !pointer { + return conversionData{ + code: fmt.Sprintf("%s, %s = %s", target, variables.error, parser), + value: target, + declaresError: true, + canError: true, } - parse += fmt.Sprintf("\n%s = %s%s", to, ref, target) } - return parse, declErr, checkErr + return conversionData{ + code: fmt.Sprintf("var %s %s\n%s, %s = %s\n%s = &%s", variables.converted, typeRef, variables.converted, variables.error, parser, target, variables.converted), + value: variables.converted, + declaresError: true, + canError: true, + } +} + +// assignConvertedValue writes a converted scalar into its final local and +// returns the concrete value expression used by validation. +func assignConvertedValue(target, converted string, pointer bool, valueVariable string) (string, string) { + if !pointer { + return fmt.Sprintf("%s = %s", target, converted), target + } + return fmt.Sprintf("%s := %s\n%s = &%s", valueVariable, converted, target, valueVariable), valueVariable +} + +// flagType returns the command-line type shown in help and conversion errors. +func (p *flagValuePlan) flagType() string { + switch p.kind { + case expr.BooleanKind: + return "BOOL" + case expr.IntKind: + return "INT" + case expr.Int32Kind: + return "INT32" + case expr.Int64Kind: + return "INT64" + case expr.UIntKind: + return "UINT" + case expr.UInt32Kind: + return "UINT32" + case expr.UInt64Kind: + return "UINT64" + case expr.Float32Kind: + return "FLOAT32" + case expr.Float64Kind: + return "FLOAT64" + case expr.StringKind, expr.BytesKind: + return "STRING" + default: + return "JSON" + } +} + +// isJSON reports whether flag text uses JSON decoding. +func (p *flagValuePlan) isJSON() bool { + return p.kind != expr.BooleanKind && p.kind != expr.IntKind && + p.kind != expr.Int32Kind && p.kind != expr.Int64Kind && + p.kind != expr.UIntKind && p.kind != expr.UInt32Kind && + p.kind != expr.UInt64Kind && p.kind != expr.Float32Kind && + p.kind != expr.Float64Kind && p.kind != expr.StringKind && + p.kind != expr.BytesKind } // goifyTerms makes valid go identifiers out of the supplied terms @@ -697,10 +1012,19 @@ func goifyTerms(terms ...string) string { return res } +// printDescription indents each line embedded in generated Go code while +// keeping blank lines free of whitespace. func printDescription(desc string) string { - res := strings.ReplaceAll(desc, "`", "`+\"`\"+`") - res = strings.ReplaceAll(res, "\n", "\n\t") - return res + desc = strings.TrimRight(desc, " \t\r\n") + lines := strings.Split(strings.ReplaceAll(desc, "`", "`+\"`\"+`"), "\n") + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "" { + lines[i] = "" + continue + } + lines[i] = "\t" + lines[i] + } + return strings.Join(lines, "\n") } func generateExample(sub *SubcommandData, svc string) { diff --git a/codegen/cli/cli_test.go b/codegen/cli/cli_test.go new file mode 100644 index 0000000000..fcfae347f7 --- /dev/null +++ b/codegen/cli/cli_test.go @@ -0,0 +1,499 @@ +// This file verifies command-line payload builders validate the concrete Go +// values produced from flag text. The tests catch regressions where validation +// is generated for a pointer and then edited as source text. +package cli + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +// TestParserVariablesKeepCollidingMethodsDistinct catches generated parsers +// that rebuild local variable names from method text. The two command names are +// different, but both become StatusUpdate when converted to a Go identifier. +func TestParserVariablesKeepCollidingMethodsDistinct(t *testing.T) { + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("generated.local/gen/jsonrpc/cli/server") + require.NoError(t, err) + parser, err := DeclareParser(pkg, "jsonrpc", "api", "server", []CommandDeclarationInput{ + {Service: "notifications", Methods: []string{"status_update", "status+update"}}, + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + const preferred = "notificationsStatusUpdate" + value := NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", nil).value + firstFlag := &FlagData{Name: "body", FullName: preferred + "Body", Type: "STRING", value: value} + secondFlag := &FlagData{Name: "body", FullName: preferred + "Body", Type: "STRING", value: value} + builder := &BuildFunctionData{ + ActualParams: []string{preferred + "Body"}, + FormalParams: []string{preferred + "Body"}, + } + command := &CommandData{ + ServiceName: "notifications", + Name: "notifications", + VarName: "notifications", + UsageDeclaration: parser.Commands["notifications"].Usage, + Subcommands: []*SubcommandData{ + { + MethodName: "status_update", + Name: "status-update", + FullName: preferred, + UsageDeclaration: parser.Commands["notifications"].Methods["status_update"], + Flags: []*FlagData{firstFlag}, + conversionFlag: firstFlag, + }, + { + MethodName: "status+update", + Name: "status+update", + FullName: preferred, + UsageDeclaration: parser.Commands["notifications"].Methods["status+update"], + Flags: []*FlagData{secondFlag}, + BuildFunction: builder, + }, + }, + } + + parser.PlanVariables([]*CommandData{command}, nil) + generated := parser.FlagsCode([]*CommandData{command}) + + require.Equal(t, "notificationsFlags", command.FlagSetVar) + require.Equal(t, preferred+"Flags2", command.Subcommands[0].FlagSetVar) + require.Equal(t, preferred+"Flags", command.Subcommands[1].FlagSetVar) + require.Equal(t, preferred+"BodyFlag2", firstFlag.PointerVar) + require.Equal(t, preferred+"BodyFlag", secondFlag.PointerVar) + require.Equal(t, "data = *"+preferred+"BodyFlag2", command.Subcommands[0].Conversion) + require.Equal(t, []string{preferred + "Body"}, builder.ActualParams) + require.Equal(t, []string{preferred + "BodyFlag"}, command.Subcommands[1].ActualPointerVars) + require.Equal(t, 4, strings.Count(generated, preferred+"Flags2")) + require.Equal(t, 1, strings.Count(generated, preferred+"BodyFlag2 =")) + + reversedPlus := &FlagData{Name: "body", FullName: preferred + "Body"} + reversedUnderscore := &FlagData{Name: "body", FullName: preferred + "Body"} + reversed := &CommandData{ + ServiceName: "notifications", + VarName: "notifications", + Subcommands: []*SubcommandData{ + {MethodName: "status+update", FullName: preferred, Flags: []*FlagData{reversedPlus}}, + {MethodName: "status_update", FullName: preferred, Flags: []*FlagData{reversedUnderscore}}, + }, + } + parser.PlanVariables([]*CommandData{reversed}, nil) + require.Equal(t, preferred+"Flags", reversed.Subcommands[0].FlagSetVar) + require.Equal(t, preferred+"Flags2", reversed.Subcommands[1].FlagSetVar) + require.Equal(t, preferred+"BodyFlag", reversedPlus.PointerVar) + require.Equal(t, preferred+"BodyFlag2", reversedUnderscore.PointerVar) +} + +// TestBuildCommandDataUsesPlannedServicePath checks that the public command +// name matches the unique path chosen while service packages are planned. +func TestBuildCommandDataUsesPlannedServicePath(t *testing.T) { + tests := []struct { + name string + service *service.Data + command string + }{ + { + name: "ordinary service", + service: &service.Data{Name: "Calculator", PathName: "calculator"}, + command: "calculator", + }, + { + name: "first colliding service", + service: &service.Data{Name: "mcp_read_value", PathName: "mcp_read_value"}, + command: "mcp-read-value", + }, + { + name: "second colliding service", + service: &service.Data{Name: "mcp-read-value", PathName: "mcp_read_value2"}, + command: "mcp-read-value2", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + command := BuildCommandData(test.service) + require.Equal(t, test.command, command.Name) + }) + } +} + +func TestFieldLoadCodeValidationTarget(t *testing.T) { + minimum := 1.0 + minLength := 1 + cases := []struct { + name string + flag *FlagData + argument string + attribute *expr.AttributeExpr + typeName string + typeRef string + wantTarget string + wantCondition string + wantError string + avoidValidation string + }{ + { + name: "optional integer", + flag: &FlagData{FullName: "serviceMethodCount", Type: "INT"}, + argument: "count", + typeName: "int", + typeRef: "int", + attribute: &expr.AttributeExpr{Type: expr.Int, Validation: &expr.ValidationExpr{Minimum: &minimum}}, + wantTarget: "val", + wantCondition: "if val < 1 {", + wantError: `goa.InvalidRangeError("count", val, 1, true)`, + avoidValidation: "if count != nil", + }, + { + name: "optional string", + flag: &FlagData{FullName: "serviceMethodState", Type: "STRING"}, + argument: "state", + typeName: "string", + typeRef: "string", + attribute: &expr.AttributeExpr{Type: expr.String, Validation: &expr.ValidationExpr{Values: []any{"ready"}}}, + wantTarget: "serviceMethodState", + wantCondition: `if !(serviceMethodState == "ready") {`, + wantError: `goa.InvalidEnumValueError("state", serviceMethodState, []any{"ready"})`, + avoidValidation: "if state != nil", + }, + { + name: "optional JSON array", + flag: &FlagData{FullName: "serviceMethodItems", Type: "JSON", Example: "[]"}, + argument: "items", + typeName: "[]string", + typeRef: "[]string", + attribute: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}, Validation: &expr.ValidationExpr{MinLength: &minLength}}, + wantTarget: "items", + wantCondition: "if len(items) < 1 {", + wantError: `goa.InvalidLengthError("items", items, len(items), 1, true)`, + avoidValidation: "if items != nil", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + context := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + var target string + validation := func(value string) string { + target = value + return codegen.AttributeValidationCode( + test.attribute, + nil, + context, + true, + false, + value, + test.argument, + ) + } + value := NewFlagPlan(test.attribute, test.typeName, test.typeRef, nil).value + + generated, declaresError := fieldLoadCode( + test.flag, + test.argument, + value, + validation, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, test.wantTarget, target) + require.Contains(t, generated, test.wantCondition) + require.Contains(t, generated, test.wantError) + require.NotContains(t, generated, test.avoidValidation) + require.True(t, declaresError) + }) + } +} + +// TestFlagArgumentTypeNameMatchesValuePlan checks the released type-name field +// against the value description used to generate the flag conversion. +func TestFlagArgumentTypeNameMatchesValuePlan(t *testing.T) { + plan := NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "Label", "Label", nil) + argument := &FlagArgData{Plan: plan, TypeName: plan.value.typeName} + require.Equal(t, plan.value.typeName, argument.TypeName) +} + +// TestLegacyFlagValidationIsEmitted verifies that plugins using the released +// Validate field still write their validation code into payload builders. +func TestLegacyFlagValidationIsEmitted(t *testing.T) { + _, builder := MakeFlags( + "Service", + &service.MethodData{Name: "Method", VarName: "Method"}, + []*FlagArgData{{ + Name: "value", + TypeName: "string", + TypeRef: "string", + Required: true, + Validate: "if value == \"\" {\n\terr = goa.MissingFieldError(\"value\", \"payload\")\n}", + }}, + &expr.Object{}, + "*Payload", + nil, + ) + + require.Contains(t, builder.Fields[0].Init, "if value == \"\"") + require.Contains(t, builder.Fields[0].Init, "goa.MissingFieldError") + require.Equal(t, "BuildMethodPayload", builder.Name) + require.True(t, builder.CheckErr) +} + +// TestFlagValidationFormsAreExclusive catches generators that silently choose +// one validation form when a plugin supplies both forms. +func TestFlagValidationFormsAreExclusive(t *testing.T) { + plan := NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", func(string) string { return "typed" }) + require.PanicsWithValue(t, "CLI flag validation cannot use both Validate and Plan", func() { + MakeFlags( + "Service", + &service.MethodData{Name: "Method"}, + []*FlagArgData{{ + Name: "value", + Plan: plan, + TypeRef: "string", + Required: true, + Validate: "legacy", + }}, + &expr.Object{}, + "*Payload", + nil, + ) + }) +} + +// TestUsageSectionsKeepReleasedData checks that plugins still receive a string +// slice when they replace the source of a generated help section. +func TestUsageSectionsKeepReleasedData(t *testing.T) { + command := &CommandData{Name: "calc", Subcommands: []*SubcommandData{{Name: "add"}}, Example: "calc add"} + require.IsType(t, []string{}, UsageCommands([]*CommandData{command}).Data) + require.IsType(t, []string{}, UsageExamples([]*CommandData{command}).Data) +} + +// TestPrintDescriptionLeavesBlankLinesEmpty catches generated help text that +// puts tabs on otherwise empty lines. +func TestPrintDescriptionLeavesBlankLinesEmpty(t *testing.T) { + require.Equal(t, "First line.\n\n\tSecond paragraph.", printDescription("First line.\n\nSecond paragraph.\n\t\n")) +} + +// TestReleasedFunctionSignaturesCompile keeps the public CLI helper calls used +// by plugins source compatible. +func TestReleasedFunctionSignaturesCompile(t *testing.T) { + var buildCommand func(*service.Data) *CommandData = BuildCommandData + var endpointFile func(string, string, []*codegen.ImportSpec, []*CommandData, *codegen.SectionTemplate) *codegen.File = EndpointParserFile + var usageCommands func([]*CommandData) *codegen.SectionTemplate = UsageCommands + var usageExamples func([]*CommandData) *codegen.SectionTemplate = UsageExamples + var flagsCode func([]*CommandData) string = FlagsCode + var newFlag func(string, string, string, string, string, bool, any, any) *FlagData = NewFlagData + var fieldLoad func(*FlagData, string, string, string, any, expr.DataType, string) (string, bool) = FieldLoadCode + + require.NotNil(t, buildCommand) + require.NotNil(t, endpointFile) + require.NotNil(t, usageCommands) + require.NotNil(t, usageExamples) + require.NotNil(t, flagsCode) + require.NotNil(t, newFlag) + require.NotNil(t, fieldLoad) +} + +// TestReleasedFlagHelpersGenerateConversions checks the string-based flag +// helpers still produce the conversion and validation requested by plugins. +func TestReleasedFlagHelpersGenerateConversions(t *testing.T) { + flag := NewFlagData("Service", "Method", "count", "int32", "", true, int32(1), nil) + generated, declaresError := FieldLoadCode( + flag, + "count", + "int32", + "if count < 1 {\n\terr = goa.InvalidRangeError(\"count\", count, 1, true)\n}", + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "INT32", flag.Type) + require.Contains(t, generated, "strconv.ParseInt(serviceMethodCount, 10, 32)") + require.Contains(t, generated, "if count < 1") + require.True(t, declaresError) +} + +// TestReleasedFlagsCodeUsesReleasedTemplateData checks that plugins can still +// render flags without creating a parser plan. +func TestReleasedFlagsCodeUsesReleasedTemplateData(t *testing.T) { + command := &CommandData{ + Name: "calc", + VarName: "calc", + Subcommands: []*SubcommandData{{ + Name: "add", + FullName: "calcAdd", + Flags: []*FlagData{{ + Name: "value", + FullName: "calcAddValue", + }}, + }}, + } + generated := FlagsCode([]*CommandData{command}) + require.Contains(t, generated, `calcFlags = flag.NewFlagSet("calc"`) + require.Contains(t, generated, `calcAddValueFlag = calcAddFlags.String("value"`) +} + +func TestFieldLoadCodeRequiredIntegerValidationUsesLoadedValue(t *testing.T) { + maximum := 42.0 + attribute := &expr.AttributeExpr{ + Type: expr.Int32, + Validation: &expr.ValidationExpr{Maximum: &maximum}, + } + context := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + var target string + validation := func(value string) string { + target = value + return codegen.AttributeValidationCode(attribute, nil, context, true, false, value, "count") + } + + generated, declaresError := fieldLoadCode( + &FlagData{FullName: "serviceMethodCount", Type: "INT32", Required: true}, + "count", + NewFlagPlan(attribute, "int32", "int32", nil).value, + validation, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "count", target) + require.Contains(t, generated, "if count > 42") + require.True(t, declaresError) +} + +func TestPrimitiveAliasFlagPlan(t *testing.T) { + alias := &expr.UserTypeExpr{ + TypeName: "Count", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.Int32, + }, + } + attribute := &expr.AttributeExpr{Type: alias} + value := NewFlagPlan(attribute, "Count", "service.Count", nil).value + flag := newFlagData("Service", "Method", "count", value, "", false, int32(3), nil) + + generated, declaresError := fieldLoadCode( + flag, + "count", + value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "INT32", flag.Type) + require.Contains(t, generated, "strconv.ParseInt(serviceMethodCount, 10, 32)") + require.Contains(t, generated, "val := service.Count(v)") + require.Contains(t, generated, "count = &val") + require.NotContains(t, generated, "json.Unmarshal") + require.True(t, declaresError) +} + +func TestCompositeFlagPlanUsesJSON(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}, + } + value := NewFlagPlan(attribute, "[]string", "[]string", nil).value + flag := newFlagData("Service", "Method", "items", value, "", false, []string{"one"}, nil) + + generated, declaresError := fieldLoadCode( + flag, + "items", + value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "JSON", flag.Type) + require.Contains(t, generated, "json.Unmarshal([]byte(serviceMethodItems), &items)") + require.True(t, declaresError) +} + +func TestStringAndBytesAliasesUseStringFlags(t *testing.T) { + cases := []struct { + name string + primitive expr.DataType + typeName string + typeRef string + wantGenerated string + }{ + { + name: "string alias", + primitive: expr.String, + typeName: "Label", + typeRef: "service.Label", + wantGenerated: "val := service.Label(serviceMethodValue)\nvalue = &val", + }, + { + name: "bytes alias", + primitive: expr.Bytes, + typeName: "Blob", + typeRef: "service.Blob", + wantGenerated: "value = service.Blob(serviceMethodValue)", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + alias := &expr.UserTypeExpr{ + TypeName: test.typeName, + AttributeExpr: &expr.AttributeExpr{Type: test.primitive}, + } + attribute := &expr.AttributeExpr{Type: alias} + value := NewFlagPlan(attribute, test.typeName, test.typeRef, nil).value + flag := newFlagData("Service", "Method", "value", value, "", false, "value", nil) + + generated, _ := fieldLoadCode( + flag, + "value", + value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "STRING", flag.Type) + require.Contains(t, generated, test.wantGenerated) + require.NotContains(t, generated, "json.Unmarshal") + }) + } +} + +func TestCustomGoTypeFlagPlanUsesJSON(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": []string{"time.Time", "time"}, + }, + } + value := NewFlagPlan(attribute, "time.Time", "time.Time", nil).value + flag := newFlagData("Service", "Method", "at", value, "", true, "2026-08-22T00:00:00Z", nil) + + generated, declaresError := fieldLoadCode( + flag, + "at", + value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "JSON", flag.Type) + require.Contains(t, generated, "json.Unmarshal([]byte(serviceMethodAt), &at)") + require.True(t, declaresError) +} diff --git a/codegen/cli/json_example_test.go b/codegen/cli/json_example_test.go new file mode 100644 index 0000000000..c551ff6d17 --- /dev/null +++ b/codegen/cli/json_example_test.go @@ -0,0 +1,41 @@ +// This file checks that CLI examples turn every supported Goa map key into a +// distinct JSON object key. +package cli + +import ( + "fmt" + "strings" + "testing" + + "goa.design/goa/v3/codegen/testutil" +) + +// TestJSONExampleFormatsPrimitiveMapKeys catches map entries being merged when +// generated help turns primitive and primitive-alias keys into JSON text. +func TestJSONExampleFormatsPrimitiveMapKeys(t *testing.T) { + type ( + exampleBool bool + exampleInt int32 + exampleUint uint64 + exampleFloat float64 + exampleString string + ) + tests := []struct { + name string + value any + }{ + {"boolean alias", map[exampleBool]string{false: "disabled", true: "enabled"}}, + {"signed alias", map[exampleInt]string{-2: "negative", 10: "positive"}}, + {"unsigned", map[uint32]string{7: "seven", 42: "forty-two"}}, + {"unsigned alias", map[exampleUint]string{9: "nine", 11: "eleven"}}, + {"floating-point alias", map[exampleFloat]string{1.25: "one", 2.5: "two"}}, + {"string", map[string]string{"first": "one", "second": "two"}}, + {"string alias", map[exampleString]string{"left": "one", "right": "two"}}, + } + + var actual strings.Builder + for _, test := range tests { + fmt.Fprintf(&actual, "%s:\n%s\n", test.name, jsonExample(test.value)) + } + testutil.AssertString(t, "testdata/golden/json_example_primitive_map_keys.golden", actual.String()) +} diff --git a/codegen/cli/symbols.go b/codegen/cli/symbols.go new file mode 100644 index 0000000000..5ab72b3733 --- /dev/null +++ b/codegen/cli/symbols.go @@ -0,0 +1,513 @@ +// This file assigns the function and local variable names used by command-line +// client files. HTTP and gRPC planning completes these names before rendering +// the shared CLI templates. +package cli + +import ( + "bytes" + "cmp" + "crypto/sha256" + "encoding/binary" + "slices" + "sort" + + "goa.design/goa/v3/codegen" +) + +type ( + // CommandDeclarationInput names one service command and each method command + // written to a parser file. + CommandDeclarationInput struct { + // Service is the design service name used to identify this command. + Service string + // Methods lists the design methods accepted by this service command. + Methods []string + } + + // ParserPlan contains the names written to one command parser package. + ParserPlan struct { + // Declarations contains the three functions shared by the parser file. + Declarations *ParserDeclarations + // Commands contains the help function names for each design service. + Commands map[string]*CommandPlan + // Variables contains the exact parameter and local names used by ParseEndpoint. + Variables *ParserVariablesData + family string + variables *codegen.NameScope + imports []string + variableNames map[parserVariableIdentity]parserVariableName + planned bool + } + + // ParserVariablesData contains the exact names of parameters and local + // values written directly by the shared HTTP and gRPC parser templates. + ParserVariablesData struct { + // ServiceName stores the selected service command name. + ServiceName string + // ServiceFlags stores the flag set for the selected service. + ServiceFlags string + // MethodName stores the selected method command name. + MethodName string + // MethodFlags stores the flag set for the selected method. + MethodFlags string + // Data stores the payload passed to the selected endpoint. + Data string + // Endpoint stores the selected Goa endpoint. + Endpoint string + // Error stores an error returned while building the payload. + Error string + // Client stores the generated transport client. + Client string + // Scheme is the HTTP URL scheme parameter. + Scheme string + // Host is the HTTP server address parameter. + Host string + // Doer is the HTTP request executor parameter. + Doer string + // Encoder is the HTTP request encoder parameter. + Encoder string + // Decoder is the HTTP response decoder parameter. + Decoder string + // Restore is the HTTP response-body restore parameter. + Restore string + // Dialer is the WebSocket dialer parameter. + Dialer string + // Connection is the gRPC connection parameter. + Connection string + // Options is the gRPC call option parameter. + Options string + // ParsedValue stores a primitive value returned by a string parser. + ParsedValue string + // ConvertedValue stores a value before it is assigned through a pointer. + ConvertedValue string + } + + // ParserLocalData describes one transport-specific parameter written in the + // generated endpoint parser. PlanVariables fills VarName before rendering. + ParserLocalData struct { + // ServiceName is the exact design service that uses this parameter. + ServiceName string + // MethodName is the exact design method that uses this parameter. It is + // empty for a service-wide parameter. + MethodName string + // Use distinguishes parameters that serve different purposes in one method. + Use string + // PreferredName is the Go name used when it does not conflict with another local. + PreferredName string + // VarName is the exact Go name written by the parameter and every use. + VarName string + } + + // CommandPlan contains the help function names for one service command. + CommandPlan struct { + // Usage is the service help function. + Usage *codegen.NameDeclaration + // Methods contains help functions indexed by design method name. + Methods map[string]*codegen.NameDeclaration + } + + // symbolOrder identifies one shared CLI function by the design names that + // select its output file and contents. + symbolOrder struct { + family string + root string + server string + service string + method string + role symbolRole + commands [sha256.Size]byte + } + + // symbolRole lists the package functions emitted by shared CLI templates. + symbolRole uint8 + + // parserVariableCandidate records one local definition and the data field + // that receives its exact Go name. + parserVariableCandidate struct { + identity parserVariableIdentity + preferred string + command *CommandData + subcommand *SubcommandData + flag *FlagData + interceptor *InterceptorData + local *ParserLocalData + } + + // parserVariableIdentity orders local definitions by their exact design + // names, so reversing input slices does not change collision suffixes. + parserVariableIdentity struct { + service string + method string + flag string + use string + role parserVariableRole + } + + // parserVariableName stores the preferred and exact name selected for one local. + parserVariableName struct { + preferred string + name string + } + + // parserVariableRole distinguishes local definitions with the same design names. + parserVariableRole uint8 +) + +const ( + parseEndpointRole symbolRole = iota + 1 + usageCommandsRole + usageExamplesRole + commandUsageRole + methodUsageRole + payloadBuilderRole +) + +const ( + serviceFlagSetVariable parserVariableRole = iota + 1 + methodFlagSetVariable + flagPointerVariable + interceptorVariable + transportVariable +) + +// DeclareParser submits every function written to one parser package. family +// is "http", "jsonrpc", or "grpc"; root and server distinguish files from +// separate designs; commands supplies the service and method help names. +func DeclareParser(pkg *codegen.GeneratedPackage, family, root, server string, commands []CommandDeclarationInput) (*ParserPlan, error) { + commandNames := commandDeclarationNames(commands) + declare := func(preferred string, role symbolRole, service, method string) (*codegen.NameDeclaration, error) { + visibility := codegen.ExportedName + if role == commandUsageRole || role == methodUsageRole { + visibility = codegen.UnexportedName + } + declaration := codegen.NewPreferredName( + codegen.NameFunction, + preferred, + visibility, + symbolOrder{family: family, root: root, server: server, service: service, method: method, role: role, commands: commandNames}, + ) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil + } + parseEndpoint, err := declare("ParseEndpoint", parseEndpointRole, "", "") + if err != nil { + return nil, err + } + usageCommands, err := declare("UsageCommands", usageCommandsRole, "", "") + if err != nil { + return nil, err + } + usageExamples, err := declare("UsageExamples", usageExamplesRole, "", "") + if err != nil { + return nil, err + } + plan := &ParserPlan{ + Declarations: &ParserDeclarations{ + ParseEndpoint: parseEndpoint, + UsageCommands: usageCommands, + UsageExamples: usageExamples, + }, + Commands: make(map[string]*CommandPlan, len(commands)), + family: family, + variables: codegen.NewNameScope(), + variableNames: make(map[parserVariableIdentity]parserVariableName), + } + for _, command := range commands { + usage, err := declare(goifyTerms(command.Service)+"Usage", commandUsageRole, command.Service, "") + if err != nil { + return nil, err + } + commandPlan := &CommandPlan{ + Usage: usage, + Methods: make(map[string]*codegen.NameDeclaration, len(command.Methods)), + } + for _, method := range command.Methods { + methodUsage, err := declare(goifyTerms(command.Service, method)+"Usage", methodUsageRole, command.Service, method) + if err != nil { + return nil, err + } + commandPlan.Methods[method] = methodUsage + } + plan.Commands[command.Service] = commandPlan + } + return plan, nil +} + +// PlanVariables chooses every local Go name written by one endpoint parser. +// data contains the shared service, method, flag, and interceptor values; +// locals contains transport-specific parameters such as multipart encoders. +func (p *ParserPlan) PlanVariables(data []*CommandData, locals []*ParserLocalData) { + candidates := parserVariableCandidates(data, locals) + sort.Slice(candidates, func(i, j int) bool { + return compareParserVariable(candidates[i], candidates[j]) < 0 + }) + if !p.planned { + p.imports = parserImportQualifiers(p.family, data) + for _, qualifier := range p.imports { + p.variables.Unique(qualifier) + } + p.Variables = planParserVariables(p.variables, p.family) + for _, candidate := range candidates { + if _, exists := p.variableNames[candidate.identity]; exists { + panic("CLI parser contains the same local variable more than once") + } + name := p.variables.Unique(candidate.preferred) + p.variableNames[candidate.identity] = parserVariableName{ + preferred: candidate.preferred, + name: name, + } + candidate.assign(name) + } + p.variables.Freeze() + p.planned = true + } else { + if !slices.Equal(p.imports, parserImportQualifiers(p.family, data)) { + panic("CLI parser imports changed after local variables were planned") + } + if len(candidates) != len(p.variableNames) { + panic("CLI parser local variables changed after planning") + } + for _, candidate := range candidates { + planned, exists := p.variableNames[candidate.identity] + if !exists || planned.preferred != candidate.preferred { + panic("CLI parser local variable changed after planning") + } + candidate.assign(planned.name) + } + } + for _, command := range data { + for _, subcommand := range command.Subcommands { + if subcommand.Interceptors != nil { + subcommand.Interceptors.ParserVar = command.Interceptors.ParserVar + } + if subcommand.BuildFunction != nil { + count := len(subcommand.BuildFunction.ActualParams) + subcommand.ActualPointerVars = make([]string, count) + for index := range count { + subcommand.ActualPointerVars[index] = subcommand.Flags[index].PointerVar + } + } + if subcommand.conversionFlag != nil { + subcommand.Conversion = directPayloadConversion(subcommand.conversionFlag, p.Variables) + } + } + } +} + +// DeclarePayloadBuilder submits the function that builds one method payload +// from command-line flags and returns the record used by its definition and +// calls. +func DeclarePayloadBuilder(pkg *codegen.GeneratedPackage, family, root, service, method, preferred string) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName( + codegen.NameFunction, + preferred, + codegen.ExportedName, + symbolOrder{family: family, root: root, service: service, method: method, role: payloadBuilderRole}, + ) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil +} + +// ComparePackageName orders CLI functions by the design and output file that +// writes them, so reversing input designs does not change their final names. +func (order symbolOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(symbolOrder) + for _, compared := range []int{ + cmp.Compare(order.family, right.family), + cmp.Compare(order.root, right.root), + cmp.Compare(order.server, right.server), + cmp.Compare(order.service, right.service), + cmp.Compare(order.method, right.method), + cmp.Compare(order.role, right.role), + } { + if compared != 0 { + return compared + } + } + return bytes.Compare(order.commands[:], right.commands[:]) +} + +// parserVariableCandidates collects each local definition before any name is +// chosen, including transport parameters supplied by the caller. +func parserVariableCandidates(data []*CommandData, locals []*ParserLocalData) []*parserVariableCandidate { + var candidates []*parserVariableCandidate + for _, command := range data { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: command.ServiceName, + role: serviceFlagSetVariable, + }, + preferred: command.VarName + "Flags", + command: command, + }) + if command.Interceptors != nil { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: command.ServiceName, + role: interceptorVariable, + }, + preferred: command.Interceptors.VarName, + interceptor: command.Interceptors, + }) + } + for _, subcommand := range command.Subcommands { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: command.ServiceName, + method: subcommand.MethodName, + role: methodFlagSetVariable, + }, + preferred: subcommand.FullName + "Flags", + subcommand: subcommand, + }) + for _, flag := range subcommand.Flags { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: command.ServiceName, + method: subcommand.MethodName, + flag: flag.Name, + role: flagPointerVariable, + }, + preferred: flag.FullName + "Flag", + flag: flag, + }) + } + } + } + for _, local := range locals { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: local.ServiceName, + method: local.MethodName, + use: local.Use, + role: transportVariable, + }, + preferred: local.PreferredName, + local: local, + }) + } + return candidates +} + +// parserImportQualifiers returns every package name referenced by ParseEndpoint. +// Reserving them first prevents a local variable from hiding an imported package. +func parserImportQualifiers(family string, data []*CommandData) []string { + qualifiers := map[string]struct{}{ + "flag": {}, + "fmt": {}, + "goa": {}, + "os": {}, + } + switch family { + case "grpc": + qualifiers["grpc"] = struct{}{} + qualifiers["json"] = struct{}{} + qualifiers["strconv"] = struct{}{} + qualifiers["utf8"] = struct{}{} + case "http", "jsonrpc": + qualifiers["goahttp"] = struct{}{} + qualifiers["http"] = struct{}{} + qualifiers["json"] = struct{}{} + qualifiers["strconv"] = struct{}{} + qualifiers["utf8"] = struct{}{} + } + for _, command := range data { + if command.PkgName != "" { + qualifiers[command.PkgName] = struct{}{} + } + if command.Interceptors != nil && command.Interceptors.PkgName != "" { + qualifiers[command.Interceptors.PkgName] = struct{}{} + } + } + result := make([]string, 0, len(qualifiers)) + for qualifier := range qualifiers { + result = append(result, qualifier) + } + sort.Strings(result) + return result +} + +// planParserVariables chooses names for parameters and local values written by +// the parser templates after all imported package names are reserved. +func planParserVariables(scope *codegen.NameScope, family string) *ParserVariablesData { + variables := &ParserVariablesData{ + ServiceName: scope.Unique("svcn"), + ServiceFlags: scope.Unique("svcf"), + MethodName: scope.Unique("epn"), + MethodFlags: scope.Unique("epf"), + Data: scope.Unique("data"), + Endpoint: scope.Unique("endpoint"), + Error: scope.Unique("err"), + Client: scope.Unique("c"), + ParsedValue: scope.Unique("v"), + ConvertedValue: scope.Unique("val"), + } + switch family { + case "grpc": + variables.Connection = scope.Unique("cc") + variables.Options = scope.Unique("opts") + case "http", "jsonrpc": + variables.Scheme = scope.Unique("scheme") + variables.Host = scope.Unique("host") + variables.Doer = scope.Unique("doer") + variables.Encoder = scope.Unique("enc") + variables.Decoder = scope.Unique("dec") + variables.Restore = scope.Unique("restore") + variables.Dialer = scope.Unique("dialer") + } + return variables +} + +// compareParserVariable orders exact design identities before the preferred Go +// spelling, so the same design always receives the same suffix. +func compareParserVariable(left, right *parserVariableCandidate) int { + for _, compared := range []int{ + cmp.Compare(left.identity.service, right.identity.service), + cmp.Compare(left.identity.method, right.identity.method), + cmp.Compare(left.identity.flag, right.identity.flag), + cmp.Compare(left.identity.use, right.identity.use), + cmp.Compare(left.identity.role, right.identity.role), + cmp.Compare(left.preferred, right.preferred), + } { + if compared != 0 { + return compared + } + } + return 0 +} + +// assign stores one exact name on the data read by its definition and uses. +func (candidate *parserVariableCandidate) assign(name string) { + switch { + case candidate.command != nil: + candidate.command.FlagSetVar = name + case candidate.subcommand != nil: + candidate.subcommand.FlagSetVar = name + case candidate.flag != nil: + candidate.flag.PointerVar = name + case candidate.interceptor != nil: + candidate.interceptor.ParserVar = name + case candidate.local != nil: + candidate.local.VarName = name + } +} + +// commandDeclarationNames returns fixed-size bytes derived from every service +// and method name written into one parser file. +func commandDeclarationNames(commands []CommandDeclarationInput) [sha256.Size]byte { + var encoded []byte + for _, command := range commands { + encoded = binary.AppendUvarint(encoded, uint64(len(command.Service))) + encoded = append(encoded, command.Service...) + encoded = binary.AppendUvarint(encoded, uint64(len(command.Methods))) + for _, method := range command.Methods { + encoded = binary.AppendUvarint(encoded, uint64(len(method))) + encoded = append(encoded, method...) + } + } + return sha256.Sum256(encoded) +} diff --git a/codegen/cli/templates.go b/codegen/cli/templates.go index 51525e204a..a64a15e85b 100644 --- a/codegen/cli/templates.go +++ b/codegen/cli/templates.go @@ -8,11 +8,12 @@ import ( // Template constants const ( - usageCommandsT = "usage_commands" - usageExamplesT = "usage_examples" - parseFlagsT = "parse_flags" - commandUsageT = "command_usage" - buildPayloadT = "build_payload" + usageCommandsT = "usage_commands" + usageExamplesT = "usage_examples" + parseFlagsT = "parse_flags" + parseFlagsPlannedT = "parse_flags_planned" + commandUsageT = "command_usage" + buildPayloadT = "build_payload" ) //go:embed templates/*.go.tpl diff --git a/codegen/cli/templates/command_usage.go.tpl b/codegen/cli/templates/command_usage.go.tpl index 9832a1425d..7436e45b39 100644 --- a/codegen/cli/templates/command_usage.go.tpl +++ b/codegen/cli/templates/command_usage.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%sUsage displays the usage of the %s command and its subcommands." .VarName .Name | comment }} -func {{ .VarName }}Usage() { +{{ printf "%s displays the usage of the %s command and its subcommands." .UsageDeclaration.Name .Name | comment }} +func {{ .UsageDeclaration.Name }}() { fmt.Fprintln(os.Stderr, `{{ printDescription .Description }}`) fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] {{ .Name }} COMMAND [flags]\n\n", os.Args[0]) fmt.Fprintln(os.Stderr, "COMMAND:") @@ -12,7 +12,7 @@ func {{ .VarName }}Usage() { } {{- range .Subcommands }} -func {{ .FullName }}Usage() { +func {{ .UsageDeclaration.Name }}() { // Header with flags fmt.Fprintf(os.Stderr, "%s [flags] {{ $.Name }} {{ .Name }}", os.Args[0]) {{- range .Flags }} diff --git a/codegen/cli/templates/parse_flags_planned.go.tpl b/codegen/cli/templates/parse_flags_planned.go.tpl new file mode 100644 index 0000000000..d6422acd75 --- /dev/null +++ b/codegen/cli/templates/parse_flags_planned.go.tpl @@ -0,0 +1,74 @@ +var ( + {{- range .Commands }} + {{ .FlagSetVar }} = flag.NewFlagSet("{{ .Name }}", flag.ContinueOnError) + {{ range .Subcommands }} + {{ .FlagSetVar }} = flag.NewFlagSet("{{ .Name }}", flag.ExitOnError) + {{- $sub := . }} + {{- range .Flags }} + {{ .PointerVar }} = {{ $sub.FlagSetVar }}.String("{{ .Name }}", "{{ if .Default }}{{ .Default }}{{ else if .Required }}REQUIRED{{ end }}", {{ printf "%q" .Description }}) + {{- end }} + {{ end }} + {{- end }} + ) + {{ range .Commands -}} + {{ $cmd := . -}} + {{ .FlagSetVar }}.Usage = {{ .UsageDeclaration.Name }} + {{ range .Subcommands -}} + {{ .FlagSetVar }}.Usage = {{ .UsageDeclaration.Name }} + {{ end }} + {{ end }} + if {{ .Variables.Error }} := flag.CommandLine.Parse(os.Args[1:]); {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + {{ .Variables.ServiceName }} string + {{ .Variables.ServiceFlags }} *flag.FlagSet + ) + { + {{ .Variables.ServiceName }} = flag.Arg(0) + switch {{ .Variables.ServiceName }} { + {{- range .Commands }} + case "{{ .Name }}": + {{ $.Variables.ServiceFlags }} = {{ .FlagSetVar }} + {{- end }} + default: + return nil, nil, fmt.Errorf("unknown service %q", {{ .Variables.ServiceName }}) + } + } + if {{ .Variables.Error }} := {{ .Variables.ServiceFlags }}.Parse(flag.Args()[1:]); {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} + } + + var ( + {{ .Variables.MethodName }} string + {{ .Variables.MethodFlags }} *flag.FlagSet + ) + { + {{ .Variables.MethodName }} = {{ .Variables.ServiceFlags }}.Arg(0) + switch {{ .Variables.ServiceName }} { + {{- range .Commands }} + case "{{ .Name }}": + switch {{ $.Variables.MethodName }} { + {{- range .Subcommands }} + case "{{ .Name }}": + {{ $.Variables.MethodFlags }} = {{ .FlagSetVar }} + {{ end }} + } + {{ end }} + } + } + if {{ .Variables.MethodFlags }} == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", {{ .Variables.ServiceName }}, {{ .Variables.MethodName }}) + } + + // Parse endpoint flags if any + if {{ .Variables.ServiceFlags }}.NArg() > 1 { + if {{ .Variables.Error }} := {{ .Variables.MethodFlags }}.Parse({{ .Variables.ServiceFlags }}.Args()[1:]); {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} + } + } diff --git a/codegen/cli/templates/usage_commands.go.tpl b/codegen/cli/templates/usage_commands.go.tpl index b63c271756..3e452fe302 100644 --- a/codegen/cli/templates/usage_commands.go.tpl +++ b/codegen/cli/templates/usage_commands.go.tpl @@ -2,7 +2,7 @@ // // command (subcommand1|subcommand2|...) // -func UsageCommands() []string { +func {{ usageName }}() []string { return []string{ {{- range . }} "{{ . }}", diff --git a/codegen/cli/templates/usage_examples.go.tpl b/codegen/cli/templates/usage_examples.go.tpl index 2fb7d84239..63ea9afb3a 100644 --- a/codegen/cli/templates/usage_examples.go.tpl +++ b/codegen/cli/templates/usage_examples.go.tpl @@ -1,5 +1,5 @@ // UsageExamples produces an example of a valid invocation of the CLI tool. -func UsageExamples() string { +func {{ usageName }}() string { return {{ range . }}os.Args[0] + " " + {{ printf "%q" . }} + "\n" + {{ end }}"" } diff --git a/codegen/cli/testdata/golden/json_example_primitive_map_keys.golden b/codegen/cli/testdata/golden/json_example_primitive_map_keys.golden new file mode 100644 index 0000000000..4c0f285ee7 --- /dev/null +++ b/codegen/cli/testdata/golden/json_example_primitive_map_keys.golden @@ -0,0 +1,35 @@ +boolean alias: +'{ + "false": "disabled", + "true": "enabled" + }' +signed alias: +'{ + "-2": "negative", + "10": "positive" + }' +unsigned: +'{ + "42": "forty-two", + "7": "seven" + }' +unsigned alias: +'{ + "11": "eleven", + "9": "nine" + }' +floating-point alias: +'{ + "1.25": "one", + "2.5": "two" + }' +string: +'{ + "first": "one", + "second": "two" + }' +string alias: +'{ + "left": "one", + "right": "two" + }' diff --git a/codegen/example/example_client.go b/codegen/example/example_client.go index 9750d255b5..8d797af71c 100644 --- a/codegen/example/example_client.go +++ b/codegen/example/example_client.go @@ -1,84 +1,103 @@ +// This file writes example command-line programs from copied server data and +// the package names already chosen for this generation. package example import ( - "os" - "path/filepath" "strings" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -// CLIFiles returns example client tool main implementation for each server -// expression in the design. -func CLIFiles(genpkg string, root *expr.RootExpr) []*codegen.File { +type ( + // clientMainData contains all design values selected before one example + // command-line client is rendered. + clientMainData struct { + // APIName is the API name written in help text. + APIName string + // Server contains the copied host, transport, and URL variable settings. + Server *clientMainServerData + // HasJSONRPC reports whether the client includes JSON-RPC commands. + HasJSONRPC bool + // HasHTTP reports whether the client includes ordinary HTTP commands. + HasHTTP bool + // UsageCommands is the sorted command list written in help text. + UsageCommands []string + // JSONRPCOnly lists commands handled only by the JSON-RPC client. + JSONRPCOnly []*jsonRPCServiceData + // WritesEndpointResult reports whether a command returns one result. + WritesEndpointResult bool + // WritesStreamResults reports whether a command receives server results. + WritesStreamResults bool + } + + // clientMainServerData contains the URL variables planned for one client + // main and the hosts that use them. + clientMainServerData struct { + *Data + // Variables lists every URL variable with its client flag names. + Variables []*mainVariableData + // Hosts lists each host with the same planned URL variables. + Hosts []*clientMainHostData + } + + // clientMainHostData contains one host and its planned URL variables. + clientMainHostData struct { + *HostData + // Variables lists the URL variables used by this host. + Variables []*mainVariableData + } +) + +// CLIFiles returns one example command-line program for each copied server. +func CLIFiles(root *Root) []*codegen.File { var fw []*codegen.File - for _, svr := range root.API.Servers { - if m := exampleCLIMain(genpkg, root, svr); m != nil { + for _, svr := range root.Servers { + if m := exampleCLIMain(root, svr); m != nil { fw = append(fw, m) } } return fw } -// exampleCLIMain returns an example client tool main implementation for the -// given server expression. -func exampleCLIMain(_ string, root *expr.RootExpr, svr *expr.ServerExpr) *codegen.File { - svrdata := Servers.Get(svr, root) - - // Skip CLI generation for servers with no transports (e.g., agent-only services) - if svrdata.DefaultTransport() == nil { +// exampleCLIMain writes the command-line program for server. +func exampleCLIMain(root *Root, server *Data) *codegen.File { + // A server with no HTTP, JSON-RPC, or gRPC service has no client to run. + if server.DefaultTransport() == nil { return nil } - path := filepath.Join("cmd", svrdata.Dir+"-cli", "main.go") - if _, err := os.Stat(path); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } - specs := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "encoding/json"}, - {Path: "errors"}, - {Path: "flag"}, - {Path: "fmt"}, - {Path: "net/url"}, - {Path: "os"}, - {Path: "sort"}, - {Path: "slices"}, - {Path: "strings"}, - codegen.GoaImport(""), + path := server.clientMainPath + main := &clientMainData{ + APIName: root.APIName, + Server: planClientMainServer(server), + HasJSONRPC: server.HasJSONRPC, + HasHTTP: server.HasHTTP, + UsageCommands: server.usageCommands, + JSONRPCOnly: server.jsonRPCOnly, + WritesEndpointResult: server.writesEndpointResult, + WritesStreamResults: server.writesStreamResults, } + specs := packageImports(server.clientPackage, clientMainFixedImports(server)) sections := []*codegen.SectionTemplate{ codegen.Header("", "main", specs), { Name: "cli-main-start", Source: exampleTemplates.Read(clientStartT), - Data: map[string]any{ - "Server": svrdata, - "HasJSONRPC": hasJSONRPC(root, svr), - "HasHTTP": hasHTTP(root, svr), - }, + Data: main, FuncMap: map[string]any{ "join": strings.Join, }, }, { Name: "cli-main-var-init", Source: exampleTemplates.Read(clientVarInitT), - Data: map[string]any{ - "Server": svrdata, - }, + Data: main, FuncMap: map[string]any{ "join": strings.Join, }, }, { Name: "cli-main-endpoint-init", Source: exampleTemplates.Read(clientEndpointInitT), - Data: map[string]any{ - "Server": svrdata, - "Root": root, - "HasJSONRPC": hasJSONRPC(root, svr), - "HasHTTP": hasHTTP(root, svr), - }, + Data: main, FuncMap: map[string]any{ "join": strings.Join, "toUpper": strings.ToUpper, @@ -86,15 +105,11 @@ func exampleCLIMain(_ string, root *expr.RootExpr, svr *expr.ServerExpr) *codege }, { Name: "cli-main-end", Source: exampleTemplates.Read(clientEndT), + Data: main, }, { Name: "cli-main-usage", Source: exampleTemplates.Read(clientUsageT), - Data: map[string]any{ - "APIName": root.API.Name, - "Server": svrdata, - "HasJSONRPC": hasJSONRPC(root, svr), - "HasHTTP": hasHTTP(root, svr), - }, + Data: main, FuncMap: map[string]any{ "toUpper": strings.ToUpper, "join": strings.Join, @@ -104,22 +119,52 @@ func exampleCLIMain(_ string, root *expr.RootExpr, svr *expr.ServerExpr) *codege return &codegen.File{Path: path, SectionTemplates: sections, SkipExist: true} } -// hasJSONRPC returns true if the server expression has a JSON-RPC server. -func hasJSONRPC(root *expr.RootExpr, svr *expr.ServerExpr) bool { - for _, s := range svr.Services { - if root.API.JSONRPC.Service(s) != nil { - return true - } +// clientMainFixedImports lists packages whose names are written directly by +// the command-line client templates. +func clientMainFixedImports(server *Data) []*codegen.ImportSpec { + specs := []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "errors"}, + {Path: "flag"}, + {Path: "fmt"}, + {Path: "net/url"}, + {Path: "os"}, + {Path: "strings"}, + } + if server.writesEndpointResult || server.writesStreamResults { + specs = append(specs, + &codegen.ImportSpec{Path: "encoding/json"}, + &codegen.ImportSpec{Path: "io"}, + ) } - return false + if server.writesEndpointResult { + specs = append(specs, codegen.GoaImport("")) + } + return specs } -// hasHTTP returns true if the server expression has an HTTP server. -func hasHTTP(root *expr.RootExpr, svr *expr.ServerExpr) bool { - for _, s := range svr.Services { - if root.API.HTTP.Service(s) != nil { - return true +// planClientMainServer selects URL flag names that are distinct from the +// built-in client flags. +func planClientMainServer(server *Data) *clientMainServerData { + fixedFlags := []string{"host", "url", "timeout", "verbose", "v"} + if server.HasJSONRPC { + fixedFlags = append(fixedFlags, "jsonrpc", "j") + } + variables := planMainVariables(server.Variables, fixedFlags) + planned := &clientMainServerData{ + Data: server, + Variables: variables.all, + Hosts: make([]*clientMainHostData, len(server.Hosts)), + } + for index, host := range server.Hosts { + plannedHost := &clientMainHostData{ + HostData: host, + Variables: make([]*mainVariableData, len(host.Variables)), + } + for variableIndex, variable := range host.Variables { + plannedHost.Variables[variableIndex] = variables.byName[variable.Name] } + planned.Hosts[index] = plannedHost } - return false + return planned } diff --git a/codegen/example/example_client_test.go b/codegen/example/example_client_test.go index f89cc823e0..35cd66c458 100644 --- a/codegen/example/example_client_test.go +++ b/codegen/example/example_client_test.go @@ -1,33 +1,53 @@ +// This file verifies that common example CLI entrypoints render without a +// second generated-module path input. package example import ( "bytes" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example/testdata" + "goa.design/goa/v3/codegen/service" + dsl "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" ) func TestExampleCLIFiles(t *testing.T) { cases := []struct { - Name string - DSL func() + Name string + DSL func() + HasEndpointResults bool + HasStreamResults bool }{ - {"no-server", testdata.NoServerDSL}, - {"single-server-single-host", testdata.SingleServerSingleHostDSL}, - {"single-server-single-host-with-variables", testdata.SingleServerSingleHostWithVariablesDSL}, - {"single-server-multiple-hosts", testdata.SingleServerMultipleHostsDSL}, - {"single-server-multiple-hosts-with-variables", testdata.SingleServerMultipleHostsWithVariablesDSL}, + {"no-server", testdata.NoServerDSL, true, false}, + {"single-server-single-host", testdata.SingleServerSingleHostDSL, true, false}, + {"single-server-single-host-with-variables", testdata.SingleServerSingleHostWithVariablesDSL, true, false}, + {"single-server-multiple-hosts", testdata.SingleServerMultipleHostsDSL, true, false}, + {"single-server-multiple-hosts-with-variables", testdata.SingleServerMultipleHostsWithVariablesDSL, true, false}, + {"server-stream", serverStreamClientDSL, false, true}, + {"input-stream", inputStreamClientDSL, false, false}, + {"mixed-results", mixedResultClientDSL, true, false}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - Servers = make(ServersData) root := codegen.RunDSL(t, c.DSL) - fs := CLIFiles("", root) + generation, err := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + rootData, ok := plan.Root(servicePlan) + require.True(t, ok) + fs := CLIFiles(rootData) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -35,8 +55,118 @@ func TestExampleCLIFiles(t *testing.T) { require.NoError(t, s.Write(&buf)) } code := codegen.FormatTestCode(t, "package foo\n"+buf.String()) + require.Equal(t, c.HasEndpointResults, strings.Contains(code, "func writeEndpointResult(")) + require.Equal(t, c.HasStreamResults, strings.Contains(code, "func writeStreamResults[")) + require.Equal(t, c.HasEndpointResults || c.HasStreamResults, strings.Contains(code, "func writeJSON(")) golden := filepath.Join("testdata", "client-"+c.Name+".golden") compareOrUpdateGolden(t, code, golden) }) } } + +var serverStreamClientDSL = func() { + dsl.Service("events", func() { + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.GRPC(func() {}) + }) + }) +} + +var inputStreamClientDSL = func() { + dsl.Service("events", func() { + dsl.Method("upload", func() { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/upload") + }) + dsl.GRPC(func() {}) + }) + }) +} + +var mixedResultClientDSL = func() { + dsl.Service("events", func() { + dsl.Method("create", func() { + dsl.Result(dsl.String) + dsl.StreamingResult(dsl.Int) + dsl.HTTP(func() { + dsl.POST("/create") + dsl.ServerSentEvents() + }) + }) + }) +} + +func TestMixedClientRoutesJSONRPCCommandsFromPlannedEndpoints(t *testing.T) { + root := codegen.RunDSL(t, mixedClientRoutingDSL) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + files := CLIFiles(plannedRoot) + require.Len(t, files, 1) + code := renderExampleSections(t, files[0]) + require.NotContains(t, code, "strings.HasPrefix(err.Error()") + require.Contains(t, code, `case "catalog":`) + require.Contains(t, code, `case "watch":`) + require.Contains(t, code, "err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout)") + require.Contains(t, code, `usageCommands := []string{`) + require.NotContains(t, code, "sort.Strings(usageCommands)") + require.NotContains(t, code, "slices.Compact(usageCommands)") + first := strings.Index(code, `"catalog read"`) + second := strings.Index(code, `"catalog watch"`) + require.GreaterOrEqual(t, first, 0) + require.Greater(t, second, first) +} + +func TestClientHostVariableValidationEmitsFixedCases(t *testing.T) { + root := codegen.RunDSL(t, testdata.SingleServerMultipleHostsWithVariablesDSL) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + code := renderExampleSections(t, CLIFiles(plannedRoot)[0]) + require.Contains(t, code, `switch *versionF`) + require.Contains(t, code, `case "v1", "v2":`) + require.NotContains(t, code, "for _, v := range []string") +} + +var mixedClientRoutingDSL = func() { + dsl.API("mixed client", func() { + dsl.Server("public", func() { + dsl.Services("catalog") + dsl.Host("development", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("catalog", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/catalog") + }) + }) + dsl.Method("watch", func() { + dsl.JSONRPC(func() {}) + }) + }) +} diff --git a/codegen/example/example_server.go b/codegen/example/example_server.go index fa58ff3f0c..f8aa049527 100644 --- a/codegen/example/example_server.go +++ b/codegen/example/example_server.go @@ -1,158 +1,172 @@ +// This file writes the shared example server from copied server data and the +// package names already chosen for this generation. package example import ( - "os" "path" - "path/filepath" + "sort" "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" - "goa.design/goa/v3/expr" ) -// ServerFiles returns an example server main implementation for every server -// expression in the service design. -func ServerFiles(genpkg string, root *expr.RootExpr, services *service.ServicesData) []*codegen.File { +type ( + // serverMainData contains every import, declaration, and local name used by + // one generated server main. + serverMainData struct { + // Server contains the listener settings and handler arguments written to the main. + Server *serverMainServerData + // Services lists the generated declarations and local names for each service. + Services []*serverMainServiceData + // APIPkg is the package name used for starter service constructors. + APIPkg string + // InterPkg is the package name used for starter interceptor constructors. + InterPkg string + // HasServices reports whether the main initializes any service endpoints. + HasServices bool + // HasInterceptors reports whether the main initializes server interceptors. + HasInterceptors bool + } + + // serverMainServerData keeps the server settings and handler arguments used + // by one generated main. + serverMainServerData struct { + *Data + // Variables lists every URL variable with its server flag names. + Variables []*mainVariableData + // Hosts contains each host and the arguments passed to its handlers. + Hosts []*serverMainHostData + } + + // serverMainHostData keeps one host and the arguments passed to each of its + // generated handlers. + serverMainHostData struct { + *HostData + // Variables lists the URL variables used by this host. + Variables []*mainVariableData + // URIs lists the host URLs and the arguments passed to their handlers. + URIs []*URIData + } + + // serverMainServiceData contains exact generated declarations and the local + // names used to connect one service to its handlers. + serverMainServiceData struct { + // Name is the design service name used to connect handler arguments. + Name string + // PkgName is the package name used for the generated service package. + PkgName string + // ServiceVar is the local variable holding the starter service. + ServiceVar string + // EndpointsVar is the local variable holding the service endpoints. + EndpointsVar string + // InterceptorsVar is the local variable holding server interceptors. + InterceptorsVar string + // HasMethods reports whether the service needs a service and endpoint value. + HasMethods bool + // HasServerInterceptors reports whether NewEndpoints takes interceptors. + HasServerInterceptors bool + // ServiceDeclaration is the exact generated service interface. + ServiceDeclaration *codegen.NameDeclaration + // EndpointsDeclaration is the exact generated endpoint collection type. + EndpointsDeclaration *codegen.NameDeclaration + // NewEndpointsDeclaration is the exact generated endpoint constructor. + NewEndpointsDeclaration *codegen.NameDeclaration + // ServerInterceptorsDeclaration is the exact generated interceptor interface. + ServerInterceptorsDeclaration *codegen.NameDeclaration + // ExampleConstructorDeclaration is the exact starter service constructor. + ExampleConstructorDeclaration *codegen.NameDeclaration + // ExampleInterceptorsConstructor is the exact starter server interceptor + // constructor. + ExampleInterceptorsConstructor *codegen.NameDeclaration + } +) + +// ServerFiles returns one example main program for each copied server. +func ServerFiles(root *Root, services *service.ServicesData) []*codegen.File { var fw []*codegen.File - for _, svr := range root.API.Servers { - if m := exampleSvrMain(genpkg, root, svr, services); m != nil { + for _, svr := range root.Servers { + if m := exampleSvrMain(svr, services); m != nil { fw = append(fw, m) } } return fw } -// APIPkg returns a unique package name for the example API implementation -// package derived from the API name. The name is registered with the given -// scope so subsequent calls return distinct names. -func APIPkg(root *expr.RootExpr, scope *codegen.NameScope) string { - return scope.Unique(strings.ToLower(codegen.Goify(root.API.Name, false)), "api") -} - -// RootPath returns the Go import path of the project root computed from the -// generated code package import path genpkg. It returns "." if genpkg has no -// parent path. +// RootPath returns the project import path that contains genpkg. func RootPath(genpkg string) string { - // genpkg is created by path.Join so the separator is / regardless of operating system - if idx := strings.LastIndex(genpkg, "/"); idx > 0 { - return genpkg[:idx] - } - return "." + return path.Dir(genpkg) } -// exampleSvrMain returns the default main function for the given server -// expression. -func exampleSvrMain(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, services *service.ServicesData) *codegen.File { - svrdata := Servers.Get(svr, root) - mainPath := filepath.Join("cmd", svrdata.Dir, "main.go") - if _, err := os.Stat(mainPath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } - specs := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "flag"}, - {Path: "fmt"}, - {Path: "net"}, - {Path: "net/url"}, - {Path: "os"}, - {Path: "os/signal"}, - {Path: "strings"}, - {Path: "sync"}, - {Path: "syscall"}, - {Path: "time"}, - {Path: "goa.design/clue/debug"}, - {Path: "goa.design/clue/log"}, - } +// exampleSvrMain writes the main program for server. +func exampleSvrMain(server *Data, services *service.ServicesData) *codegen.File { + mainPath := server.serverMainPath + outputPackage := server.serverPackage.ImportPath() + specs := packageImports(server.serverPackage, serverMainFixedImports()) - // Iterate through services listed in the server expression. - svcData := make([]*service.Data, len(svr.Services)) - scope := codegen.NewNameScope() + // Load the generated information for each service hosted by this server. + svcData := make([]*service.Data, len(server.Services)) hasInterceptors := false - for i, svc := range svr.Services { + serviceImports := make(map[string]struct{}, len(server.Services)) + servicePackages := make(map[string]string, len(server.Services)) + for i, svc := range server.Services { sd := services.Get(svc) svcData[i] = sd - specs = append(specs, &codegen.ImportSpec{ - Path: path.Join(genpkg, sd.PathName), - Name: scope.Unique(sd.PkgName, "svc"), - }) + serviceImport := services.ServiceImport(outputPackage, svc) + servicePackages[svc] = serviceImport.Name + if _, exists := serviceImports[serviceImport.Path]; !exists { + specs = append(specs, serviceImport) + serviceImports[serviceImport.Path] = struct{}{} + } hasInterceptors = hasInterceptors || len(sd.ServerInterceptors) > 0 } - interPkg := scope.Unique("interceptors", "ex") - - rootPath := RootPath(genpkg) - apiPkg := APIPkg(root, scope) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: apiPkg}) + rootPath := path.Dir(services.GenPkg()) + apiImport := services.PackageImport(outputPackage, rootPath) + apiPkg := apiImport.Name + specs = append(specs, apiImport) + var interPkg string if hasInterceptors { - specs = append(specs, &codegen.ImportSpec{Path: path.Join(rootPath, "interceptors"), Name: interPkg}) + interceptorImport := services.PackageImport(outputPackage, rootPath+"/interceptors") + interPkg = interceptorImport.Name + specs = append(specs, interceptorImport) } + main := planServerMain(server, svcData, servicePackages, apiPkg, interPkg) sections := []*codegen.SectionTemplate{ codegen.Header("", "main", specs), { Name: "server-main-start", Source: exampleTemplates.Read(serverStartT), - Data: map[string]any{ - "Server": svrdata, - }, + Data: main, FuncMap: map[string]any{ "join": strings.Join, }, }, { Name: "server-main-logger", Source: exampleTemplates.Read(serverLoggerT), - Data: map[string]any{ - "APIPkg": apiPkg, - "Server": svrdata, - }, + Data: main, }, { Name: "server-main-services", Source: exampleTemplates.Read(serverServicesT), - Data: map[string]any{ - "APIPkg": apiPkg, - "Services": svcData, - }, - FuncMap: map[string]any{ - "mustInitServices": mustInitServices, - }, + Data: main, }, { Name: "server-main-interceptors", Source: exampleTemplates.Read(serverInterceptorsT), - Data: map[string]any{ - "APIPkg": apiPkg, - "InterPkg": interPkg, - "Services": svcData, - "HasInterceptors": hasInterceptors, - }, - FuncMap: map[string]any{ - "mustInitServices": mustInitServices, - }, + Data: main, }, { Name: "server-main-endpoints", Source: exampleTemplates.Read(serverEndpointsT), - Data: map[string]any{ - "Services": svcData, - }, - FuncMap: map[string]any{ - "mustInitServices": mustInitServices, - }, + Data: main, }, { Name: "server-main-interrupts", Source: exampleTemplates.Read(serverInterruptsT), }, { Name: "server-main-handler", Source: exampleTemplates.Read(serverHandlerT), - Data: map[string]any{ - "Server": svrdata, - "Services": svcData, - }, + Data: main, FuncMap: map[string]any{ - "goify": codegen.Goify, "join": strings.Join, "toUpper": strings.ToUpper, - "hasJSONRPCEndpoints": func(svcData *service.Data) bool { - return hasJSONRPCEndpoints(root, svcData) - }, }, }, { @@ -164,23 +178,131 @@ func exampleSvrMain(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, se return &codegen.File{Path: mainPath, SectionTemplates: sections, SkipExist: true} } -// mustInitServices returns true if at least one of the services defines methods. -// It is used by the template to initialize service variables. -func mustInitServices(data []*service.Data) bool { - for _, svc := range data { - if len(svc.Methods) > 0 { - return true +// serverMainFixedImports lists packages whose names are written directly by +// the server main templates. +func serverMainFixedImports() []*codegen.ImportSpec { + return []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "flag"}, + {Path: "fmt"}, + {Path: "net"}, + {Path: "net/url"}, + {Path: "os"}, + {Path: "os/signal"}, + {Path: "strings"}, + {Path: "sync"}, + {Path: "syscall"}, + {Path: "time"}, + {Path: "goa.design/clue/debug"}, + {Path: "goa.design/clue/log"}, + } +} + +// planServerMain chooses every local name once and connects each handler +// argument to the matching service variable. +func planServerMain( + server *Data, + services []*service.Data, + packages map[string]string, + apiPkg, interPkg string, +) *serverMainData { + scope := codegen.NewNameScope() + importNames := map[string]struct{}{apiPkg: {}} + if interPkg != "" { + importNames[interPkg] = struct{}{} + } + for _, packageName := range packages { + importNames[packageName] = struct{}{} + } + orderedImports := make([]string, 0, len(importNames)) + for name := range importNames { + orderedImports = append(orderedImports, name) + } + sort.Strings(orderedImports) + for _, name := range orderedImports { + scope.Unique(name) + } + for _, name := range []string{ + "addr", "c", "cancel", "context", "ctx", "debug", "err", "errc", + "flag", "fmt", "format", "h", "log", "net", "os", "signal", "strings", + "sync", "syscall", "time", "u", "url", "wg", + } { + scope.Unique(name) + } + byName := make(map[string]*serverMainServiceData, len(services)) + main := &serverMainData{ + APIPkg: apiPkg, + InterPkg: interPkg, + HasInterceptors: interPkg != "", + } + for _, serviceData := range services { + planned := &serverMainServiceData{ + Name: serviceData.Name, + PkgName: packages[serviceData.Name], + HasMethods: len(serviceData.Methods) > 0, + HasServerInterceptors: len(serviceData.ServerInterceptors) > 0, + ServiceDeclaration: serviceData.ServiceDeclaration, + EndpointsDeclaration: serviceData.EndpointsDeclaration, + NewEndpointsDeclaration: serviceData.NewEndpointsDeclaration, + ServerInterceptorsDeclaration: serviceData.ServerInterceptorsDeclaration, + ExampleConstructorDeclaration: serviceData.ExampleConstructorDeclaration, + ExampleInterceptorsConstructor: serviceData.ExampleServerInterceptorsConstructorDeclaration, + } + if planned.HasMethods { + base := codegen.Goify(serviceData.Name, false) + planned.ServiceVar = scope.Unique(base + "Svc") + planned.EndpointsVar = scope.Unique(base + "Endpoints") + if planned.HasServerInterceptors { + planned.InterceptorsVar = scope.Unique(base + "Interceptors") + } + main.HasServices = true } + main.Services = append(main.Services, planned) + byName[planned.Name] = planned } - return false + main.Server = planServerMainHandlers(server, byName) + return main } -// hasJSONRPCEndpoints returns true if the service has JSON-RPC endpoints. -func hasJSONRPCEndpoints(root *expr.RootExpr, data *service.Data) bool { - for _, svc := range root.API.JSONRPC.Services { - if svc.Name() == data.Name { - return true +// planServerMainHandlers copies each host and replaces service names with the +// local variables chosen for this main function. +func planServerMainHandlers(server *Data, services map[string]*serverMainServiceData) *serverMainServerData { + fixedFlags := make([]string, 0, 4+len(server.Transports)) + fixedFlags = append(fixedFlags, "host", "domain", "secure", "debug") + for _, transport := range server.Transports { + fixedFlags = append(fixedFlags, string(transport.Type)+"-port") + } + variables := planMainVariables(server.Variables, fixedFlags) + planned := &serverMainServerData{ + Data: server, + Variables: variables.all, + Hosts: make([]*serverMainHostData, len(server.Hosts)), + } + for hostIndex, host := range server.Hosts { + plannedHost := &serverMainHostData{ + HostData: host, + Variables: make([]*mainVariableData, len(host.Variables)), + URIs: make([]*URIData, len(host.URIs)), + } + for variableIndex, variable := range host.Variables { + plannedHost.Variables[variableIndex] = variables.byName[variable.Name] + } + for uriIndex, uri := range host.URIs { + plannedURI := *uri + plannedURI.HandlerArgs = make([]HandlerArg, len(uri.HandlerArgs)) + for argIndex, arg := range uri.HandlerArgs { + plannedArg := arg + service := services[arg.Service] + if arg.Endpoint { + plannedArg.Variable = service.EndpointsVar + } else { + plannedArg.Variable = service.ServiceVar + } + plannedURI.HandlerArgs[argIndex] = plannedArg + } + plannedHost.URIs[uriIndex] = &plannedURI } + planned.Hosts[hostIndex] = plannedHost } - return false + return planned } diff --git a/codegen/example/example_server_test.go b/codegen/example/example_server_test.go index 59f2242683..b347eb0b33 100644 --- a/codegen/example/example_server_test.go +++ b/codegen/example/example_server_test.go @@ -1,3 +1,5 @@ +// This file verifies that generated example servers and command-line programs +// contain the service and transport wiring required by representative designs. package example import ( @@ -14,6 +16,9 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example/testdata" "goa.design/goa/v3/codegen/service" + dsl "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" ) // updateGolden is true when -w is passed to `go test`, e.g. `go test ./... -w` @@ -59,10 +64,19 @@ func TestExampleServerFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - Servers = make(ServersData) root := codegen.RunDSL(t, c.DSL) - services := service.NewServicesData(root) - fs := ServerFiles("", root, services) + generation, err := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + examplePlan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + rootData, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() + fs := ServerFiles(rootData, services) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -75,3 +89,131 @@ func TestExampleServerFiles(t *testing.T) { }) } } + +func TestGRPCOnlyServerLoggerDoesNotUseHTTPPort(t *testing.T) { + root := codegen.RunDSL(t, testdata.ServiceForOnlyGRPCDSL) + generation, err := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + examplePlan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + rootData, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + files := ServerFiles(rootData, servicePlan.Services()) + require.Len(t, files, 1) + + var code bytes.Buffer + for _, section := range files[0].SectionTemplates { + require.NoError(t, section.Write(&code)) + } + require.NotContains(t, code.String(), "httpPortF") + require.Contains(t, code.String(), `log.KV{K: "grpc-port", V: *grpcPortF}`) +} + +func TestServerMainUsesPlannedDeclarationsAndDistinctLocals(t *testing.T) { + root := codegen.RunDSL(t, collidingServerServicesDSL) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + examplePlan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + services := servicePlan.Services() + files := ServerFiles(plannedRoot, services) + require.Len(t, files, 1) + code := renderExampleSections(t, files[0]) + + first := services.Get("foo-bar") + second := services.Get("foo bar") + outputPackage := plannedRoot.Servers[0].serverPackage.ImportPath() + serviceImport := services.ServiceImport(outputPackage, first.Name) + require.Contains(t, code, serviceImport.Name+` "`+serviceImport.Path+`"`) + require.Contains(t, code, serviceImport.Name+"."+first.ServiceDeclaration.Name()) + require.Contains(t, code, serviceImport.Name+"."+second.ServiceDeclaration.Name()) + require.Contains(t, code, "."+first.ExampleConstructorDeclaration.Name()+"()") + require.Contains(t, code, "."+second.ExampleConstructorDeclaration.Name()+"()") + require.Contains(t, code, "."+first.NewEndpointsDeclaration.Name()+"(") + require.Contains(t, code, "."+second.NewEndpointsDeclaration.Name()+"(") + require.Contains(t, code, "."+first.ServerInterceptorsDeclaration.Name()) + require.Contains(t, code, "."+second.ServerInterceptorsDeclaration.Name()) + require.Contains(t, code, "."+first.ExampleServerInterceptorsConstructorDeclaration.Name()+"()") + require.Contains(t, code, "."+second.ExampleServerInterceptorsConstructorDeclaration.Name()+"()") + require.Contains(t, code, "fooBarSvc2") + require.Contains(t, code, "fooBarEndpoints2") + require.Contains(t, code, "fooBarInterceptors2") +} + +// TestServerMainUsesItsOutputPackageImportNames checks that clue/log keeps the +// name log in cmd/public/main.go. The application receives log2, and every +// generated call uses that selected name. +func TestServerMainUsesItsOutputPackageImportNames(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("log", func() { + dsl.Server("public", func() { + dsl.Services("status") + dsl.Host("development", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + examplePlan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + + files := ServerFiles(plannedRoot, servicePlan.Services()) + require.Len(t, files, 1) + code := renderExampleSections(t, files[0]) + require.Contains(t, code, `log2 "example.local"`) + require.Contains(t, code, `"goa.design/clue/log"`) + require.Contains(t, code, "log2.NewStatus()") +} + +var collidingServerServicesDSL = func() { + trace := dsl.Interceptor("trace") + dsl.API("colliding server", func() { + dsl.Server("public", func() { + dsl.Services("foo-bar", "foo bar") + dsl.Host("development", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("foo-bar", func() { + dsl.ServerInterceptor(trace) + dsl.Method("first", func() { + dsl.HTTP(func() { + dsl.GET("/first") + }) + }) + }) + dsl.Service("foo bar", func() { + dsl.ServerInterceptor(trace) + dsl.Method("second", func() { + dsl.HTTP(func() { + dsl.GET("/second") + }) + }) + }) +} diff --git a/codegen/example/plan.go b/codegen/example/plan.go new file mode 100644 index 0000000000..cffc3e5b4f --- /dev/null +++ b/codegen/example/plan.go @@ -0,0 +1,133 @@ +// This file copies the server information used by generated examples and +// records every package imported by their server and client programs. +package example + +import ( + "path" + "path/filepath" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" +) + +type ( + // Plan stores copied API, service, server, and JSON-RPC names for one + // example generation. + Plan struct { + rootByService map[*service.Plan]*Root + } + + // Root stores the API name, service names, and server descriptions copied + // from one design. + Root struct { + // APIName is the design API name written in example help text. + APIName string + // Services lists every design service in declaration order. + Services []string + // Servers lists the copied server values in declaration order. + Servers []*Data + } +) + +// NewPlan copies the server information from each service plan and records the +// imports used by every generated server and command-line client. +func NewPlan(generation *codegen.Generation, services ...*service.Plan) (*Plan, error) { + plan := &Plan{rootByService: make(map[*service.Plan]*Root, len(services))} + for _, servicePlan := range services { + design := servicePlan.Root() + plannedRoot := &Root{ + APIName: design.API.Name, + Services: make([]string, len(design.Services)), + Servers: make([]*Data, len(design.API.Servers)), + } + for i, service := range design.Services { + plannedRoot.Services[i] = service.Name + } + for i, server := range design.API.Servers { + planned := buildServerData(server, design) + if err := planMainPackages(generation, servicePlan, planned); err != nil { + return nil, err + } + plannedRoot.Servers[i] = planned + } + plan.rootByService[servicePlan] = plannedRoot + } + return plan, nil +} + +// Root returns the copied design description for servicePlan. The second +// result is false when servicePlan was not used to create this plan. +func (p *Plan) Root(servicePlan *service.Plan) (*Root, bool) { + root, ok := p.rootByService[servicePlan] + return root, ok +} + +// planMainPackages records the imports used by one generated server and its +// command-line client before generation chooses their Go names. +func planMainPackages(generation *codegen.Generation, servicePlan *service.Plan, server *Data) error { + rootPath := RootPath(generation.GenPkg()) + serverPath := path.Join(rootPath, "cmd", server.Dir) + serverPackage, err := generation.ClaimOutputPackage(serverPath, path.Dir(filepath.ToSlash(server.serverMainPath))) + if err != nil { + return err + } + server.serverPackage = serverPackage + generated := make([]*codegen.ImportSpec, 0, len(server.Services)+2) + for _, serviceName := range server.Services { + serviceImport, _, err := servicePlan.ServicePackageImports(servicePlan.Root().Service(serviceName)) + if err != nil { + return err + } + generated = append(generated, serviceImport) + } + hasInterceptors := false + for _, serviceName := range server.Services { + hasInterceptors = hasInterceptors || len(servicePlan.Root().Service(serviceName).ServerInterceptors) > 0 + } + for _, spec := range servicePlan.ExampleImports() { + if spec.Path == path.Join(rootPath, "interceptors") && !hasInterceptors { + continue + } + generated = append(generated, spec) + } + if err := registerPackageImports(serverPackage, serverMainFixedImports(), generated); err != nil { + return err + } + + if server.DefaultTransport() == nil { + return nil + } + clientPath := path.Join(rootPath, "cmd", server.Dir+"-cli") + clientPackage, err := generation.ClaimOutputPackage(clientPath, path.Dir(filepath.ToSlash(server.clientMainPath))) + if err != nil { + return err + } + server.clientPackage = clientPackage + return registerPackageImports(clientPackage, clientMainFixedImports(server), nil) +} + +// registerPackageImports records names written directly in templates first. +// Generated packages receive another name when a template already uses theirs. +func registerPackageImports(owner *codegen.GeneratedPackage, fixed, generated []*codegen.ImportSpec) error { + for _, spec := range fixed { + if err := owner.RequireImport(spec); err != nil { + return err + } + } + for _, spec := range generated { + if err := owner.ReserveGeneratedImport(spec); err != nil { + return err + } + } + return nil +} + +// packageImports returns the import declarations chosen for one generated +// file after generation has made every package name final. +func packageImports(owner *codegen.GeneratedPackage, planned []*codegen.ImportSpec) []*codegen.ImportSpec { + imports := make([]*codegen.ImportSpec, len(planned)) + for index, spec := range planned { + imports[index] = owner.Import(spec.Path) + } + return imports +} diff --git a/codegen/example/plan_test.go b/codegen/example/plan_test.go new file mode 100644 index 0000000000..2ce27d0422 --- /dev/null +++ b/codegen/example/plan_test.go @@ -0,0 +1,283 @@ +// This file checks that each example generation keeps its copied server data +// separate from every other generation. +package example + +import ( + "bytes" + "reflect" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example/testdata" + "goa.design/goa/v3/codegen/service" + dsl "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestPlansKeepSameNamedServersSeparate(t *testing.T) { + httpRoot := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + grpcRoot := codegen.RunDSL(t, testdata.ServiceForOnlyGRPCDSL) + + httpGeneration, err := codegen.NewGeneration("example.local/http/gen", []eval.Root{httpRoot}) + require.NoError(t, err) + grpcGeneration, err := codegen.NewGeneration("example.local/grpc/gen", []eval.Root{grpcRoot}) + require.NoError(t, err) + + httpService, err := service.NewPlan(httpRoot, httpGeneration, expr.NewExampleGenerator(httpRoot.API.RandomizerFactory)) + require.NoError(t, err) + httpPlan, err := NewPlan(httpGeneration, httpService) + require.NoError(t, err) + httpData, ok := httpPlan.Root(httpService) + require.True(t, ok) + httpServer := httpData.Servers[0] + require.True(t, httpServer.HasHTTP) + require.False(t, httpServer.HasTransport(TransportGRPC)) + + grpcService, err := service.NewPlan(grpcRoot, grpcGeneration, expr.NewExampleGenerator(grpcRoot.API.RandomizerFactory)) + require.NoError(t, err) + grpcPlan, err := NewPlan(grpcGeneration, grpcService) + require.NoError(t, err) + grpcData, ok := grpcPlan.Root(grpcService) + require.True(t, ok) + grpcServer := grpcData.Servers[0] + require.False(t, grpcServer.HasHTTP) + require.True(t, grpcServer.HasTransport(TransportGRPC)) + + require.True(t, httpServer.HasHTTP) + require.False(t, httpServer.HasTransport(TransportGRPC)) +} + +func TestPlanKeepsHostVariableDefaultAndAllowedValues(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("host variables", func() { + dsl.Server("public", func() { + dsl.Services("status") + dsl.Host("production", func() { + dsl.URI("https://{region}.example.com") + dsl.Variable("region", dsl.String, func() { + dsl.Default("west") + dsl.Enum("west", "east") + }) + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + variable := plannedRoot.Servers[0].Hosts[0].Variables[0] + require.Equal(t, "west", variable.DefaultValue) + require.Equal(t, []string{"west", "east"}, variable.Values) +} + +// TestPlanUsesURLRoleWhenAHostVariableMatchesABuiltInFlag checks that a +// generated flag says what it configures instead of receiving a number. +func TestPlanUsesURLRoleWhenAHostVariableMatchesABuiltInFlag(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("host variable collision", func() { + dsl.Server("public", func() { + dsl.Services("status") + dsl.Host("production", func() { + dsl.URI("https://{host}.example.com") + dsl.Variable("host", dsl.String, func() { + dsl.Default("west") + dsl.Enum("west", "east") + }) + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + plannedClient := planClientMainServer(plannedRoot.Servers[0]) + variable := plannedClient.Variables[0] + require.Equal(t, "url-host", variable.FlagName) + require.Equal(t, "urlHostF", variable.VarName) + require.Same(t, variable, plannedClient.Hosts[0].Variables[0]) + + plannedServer := planMainVariables(plannedRoot.Servers[0].Variables, []string{"host"}) + require.Equal(t, "url-host", plannedServer.all[0].FlagName) + require.Equal(t, "urlHostF", plannedServer.all[0].VarName) +} + +// TestPlanFindsRootForExactServicePlan checks that copied server data belongs +// only to the service plan from which it was built. +func TestPlanFindsRootForExactServicePlan(t *testing.T) { + firstRoot := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + secondRoot := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + firstGeneration, err := codegen.NewGeneration("example.local/first/gen", []eval.Root{firstRoot}) + require.NoError(t, err) + firstService, err := service.NewPlan(firstRoot, firstGeneration, expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)) + require.NoError(t, err) + secondGeneration, err := codegen.NewGeneration("example.local/second/gen", []eval.Root{secondRoot}) + require.NoError(t, err) + secondService, err := service.NewPlan(secondRoot, secondGeneration, expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(firstGeneration, firstService) + require.NoError(t, err) + + root, ok := plan.Root(firstService) + require.True(t, ok) + require.Equal(t, firstRoot.API.Name, root.APIName) + _, ok = plan.Root(secondService) + require.False(t, ok) +} + +func TestPlansBuildConcurrently(t *testing.T) { + httpRoot := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + grpcRoot := codegen.RunDSL(t, testdata.ServiceForOnlyGRPCDSL) + httpGeneration, err := codegen.NewGeneration("example.local/http/gen", []eval.Root{httpRoot}) + require.NoError(t, err) + grpcGeneration, err := codegen.NewGeneration("example.local/grpc/gen", []eval.Root{grpcRoot}) + require.NoError(t, err) + httpService, err := service.NewPlan(httpRoot, httpGeneration, expr.NewExampleGenerator(httpRoot.API.RandomizerFactory)) + require.NoError(t, err) + grpcService, err := service.NewPlan(grpcRoot, grpcGeneration, expr.NewExampleGenerator(grpcRoot.API.RandomizerFactory)) + require.NoError(t, err) + + start := make(chan struct{}) + var ( + plans [2]*Plan + errs [2]error + ready sync.WaitGroup + wait sync.WaitGroup + ) + build := func(index int, generation *codegen.Generation, servicePlan *service.Plan) { + defer wait.Done() + ready.Done() + <-start + plans[index], errs[index] = NewPlan(generation, servicePlan) + } + ready.Add(2) + wait.Add(2) + go build(0, httpGeneration, httpService) + go build(1, grpcGeneration, grpcService) + ready.Wait() + close(start) + wait.Wait() + + require.NoError(t, errs[0]) + require.NoError(t, errs[1]) + httpData, ok := plans[0].Root(httpService) + require.True(t, ok) + grpcData, ok := plans[1].Root(grpcService) + require.True(t, ok) + require.True(t, httpData.Servers[0].HasHTTP) + require.False(t, grpcData.Servers[0].HasHTTP) +} + +// TestPlanCopiesEveryServerValue checks that example output does not keep a +// path back to the design values it copied. +func TestPlanCopiesEveryServerValue(t *testing.T) { + root := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + generation, err := codegen.NewGeneration("example.local/http/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + + copied, ok := plan.Root(servicePlan) + require.True(t, ok) + server := root.API.Servers[0] + require.False(t, pointsToDesignValue(reflect.ValueOf(copied), map[uintptr]struct{}{ + reflect.ValueOf(root).Pointer(): {}, + reflect.ValueOf(server).Pointer(): {}, + }, make(map[uintptr]struct{}))) + + files := CLIFiles(copied) + require.NotEmpty(t, files) + before := renderExampleSections(t, files[0]) + root.API.Name = "changed api" + server.Name = "changed server" + server.Description = "changed description" + server.Services = nil + server.Hosts = nil + require.Equal(t, before, renderExampleSections(t, files[0])) +} + +// renderExampleSections writes the complete file without touching the file +// system so a test can compare the exact generated text. +func renderExampleSections(t *testing.T, file *codegen.File) string { + t.Helper() + var output bytes.Buffer + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&output)) + } + return output.String() +} + +// pointsToDesignValue reports whether value contains one of the original +// design pointers. visited prevents loops in linked values. +func pointsToDesignValue(value reflect.Value, targets, visited map[uintptr]struct{}) bool { + if !value.IsValid() { + return false + } + switch value.Kind() { + case reflect.Interface: + return pointsToDesignValue(value.Elem(), targets, visited) + case reflect.Pointer: + pointer := value.Pointer() + if _, ok := targets[pointer]; ok { + return true + } + if _, ok := visited[pointer]; ok { + return false + } + visited[pointer] = struct{}{} + return pointsToDesignValue(value.Elem(), targets, visited) + case reflect.Map: + for iterator := value.MapRange(); iterator.Next(); { + if pointsToDesignValue(iterator.Key(), targets, visited) || + pointsToDesignValue(iterator.Value(), targets, visited) { + return true + } + } + case reflect.Slice, reflect.Array: + for index := 0; index < value.Len(); index++ { + if pointsToDesignValue(value.Index(index), targets, visited) { + return true + } + } + case reflect.Struct: + for index := 0; index < value.NumField(); index++ { + if pointsToDesignValue(value.Field(index), targets, visited) { + return true + } + } + } + return false +} diff --git a/codegen/example/public_api_test.go b/codegen/example/public_api_test.go new file mode 100644 index 0000000000..cf86b23650 --- /dev/null +++ b/codegen/example/public_api_test.go @@ -0,0 +1,15 @@ +// This file protects small released helpers that plugins and generator tools +// can use without rebuilding example-generation data. +package example + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRootPathReturnsProjectImportPath checks the released project path helper. +func TestRootPathReturnsProjectImportPath(t *testing.T) { + require.Equal(t, "goa.design/calc", RootPath("goa.design/calc/gen")) + require.Equal(t, ".", RootPath("gen")) +} diff --git a/codegen/example/server_data.go b/codegen/example/server_data.go index 830a637bfd..ea7e75e51c 100644 --- a/codegen/example/server_data.go +++ b/codegen/example/server_data.go @@ -1,7 +1,12 @@ +// This file copies server, host, URL, and transport values used to write +// example programs. package example import ( "fmt" + "path/filepath" + "slices" + "sort" "strconv" "strings" @@ -9,15 +14,7 @@ import ( "goa.design/goa/v3/expr" ) -// Servers holds the server data needed to generate the example service and -// client. It is computed from the Server expressions in the service design. -var Servers = make(ServersData) - type ( - // ServersData holds the server data from the service design indexed by - // server name. - ServersData map[string]*Data - // Data contains the data about a single server. Data struct { // Name is the server name. @@ -35,7 +32,29 @@ type ( // Transports is the list of transports defined in the server. Transports []*TransportData // Dir is the directory name for the generated client and server examples. - Dir string + Dir string + serverMainPath string + clientMainPath string + // serverPackage stores the import names selected for cmd/ files. + serverPackage *codegen.GeneratedPackage + // clientPackage stores the import names selected for cmd/-cli files. + clientPackage *codegen.GeneratedPackage + // HasHTTP reports whether the server exposes an ordinary HTTP service. + HasHTTP bool + // HasJSONRPC reports whether the server exposes a JSON-RPC service. + HasJSONRPC bool + writesEndpointResult bool + writesStreamResults bool + usageCommands []string + jsonRPCOnly []*jsonRPCServiceData + } + + // jsonRPCServiceData lists the JSON-RPC-only endpoints for one service. + jsonRPCServiceData struct { + // Service is the command-line service name. + Service string + // Endpoints lists command-line endpoint names. + Endpoints []string } // HostData contains the data about a single host in a server. @@ -44,8 +63,7 @@ type ( Name string // Description is the host description. Description string - // Schemes is the list of schemes supported by the host. It is computed - // from the URI expressions defined in the Host. + // Schemes lists the protocols used by the host URLs. // Possible values are http, https, grpc, grpcs. Schemes []string // URIs is the list of URLs defined in the host. @@ -60,19 +78,31 @@ type ( Name string // Description is the variable description. Description string - // VarName is the variable name used in generating flag variables. - VarName string - // DefaultValue is the default value for the variable. It is set to the - // default value defined in the variable attribute if exists, or else set - // to the first value in the enum expression. + // DefaultValue is the configured default, or the first allowed value when + // no default was configured. DefaultValue string - // Values is the list of allowed values for the variable. The values can - // only be primitives. We convert the primitives into string type so that - // we could use them to replace the URL variables in the example - // generation. + // Values lists the allowed values as text so the generated program can + // replace variables in a URL. Values []string } + // mainVariableData contains the exact command-line and Go names selected + // for one URL variable in one generated main program. + mainVariableData struct { + *VariableData + // FlagName is the exact command-line flag name. + FlagName string + // VarName is the exact Go variable name holding the flag value. + VarName string + } + + // mainVariables contains every planned URL variable and provides the same + // planned value to each host that uses it. + mainVariables struct { + all []*mainVariableData + byName map[string]*mainVariableData + } + // URIData contains the data about a URL. URIData struct { // URL is the underlying URL. @@ -84,17 +114,20 @@ type ( Port string // Transport is the transport type for the URL. Transport *TransportData - // HandlerArgs are the precomputed handler arguments for this URI used by - // the example server template. Each entry may contain an Endpoint and/or - // Service argument name to be passed to the handler in order. + // HandlerArgs lists the service values passed to the generated handler in + // call order. The generated main adds each local variable name later. HandlerArgs []HandlerArg } - // HandlerArg represents one argument slot to the handler call in the example - // server. Only one of Endpoint or Service may be set for each entry. + // HandlerArg identifies one service or endpoint value passed to a generated + // transport handler. HandlerArg struct { - Endpoint string - Service string + // Service is the design service name. + Service string + // Endpoint is true when the handler receives the service's endpoint collection. + Endpoint bool + // Variable is the local variable passed by the generated main. + Variable string } // TransportData contains the data about a transport (http or grpc). @@ -118,18 +151,7 @@ const ( TransportGRPC = "grpc" ) -// Get returns the server data for the given server expression. It builds the -// server data if the server name does not exist in the map. -func (d ServersData) Get(svr *expr.ServerExpr, root *expr.RootExpr) *Data { - if data, ok := d[svr.Name]; ok { - return data - } - sd := buildServerData(svr, root) - d[svr.Name] = sd - return sd -} - -// DefaultHost returns the first host defined in the server expression. +// DefaultHost returns the server's first host. func (s *Data) DefaultHost() *HostData { if len(s.Hosts) == 0 { return nil @@ -157,7 +179,7 @@ func (s *Data) DefaultTransport() *TransportData { return t } } - return nil // bug + return nil } // HasTransport checks if the server supports the given transport. @@ -170,6 +192,20 @@ func (s *Data) HasTransport(transport Transport) bool { return false } +// HandlerArgs returns the ordered service values accepted by the handler for +// transport. Every host using the same transport has the same arguments. It +// panics when the server does not use transport. +func (s *Data) HandlerArgs(transport Transport) []HandlerArg { + for _, host := range s.Hosts { + for _, uri := range host.URIs { + if uri.Transport.Type == transport { + return uri.HandlerArgs + } + } + } + panic(fmt.Sprintf("server %q does not use the %s transport", s.Name, transport)) +} + // DefaultURL returns the first URL defined for the given transport in a host. func (h *HostData) DefaultURL(transport Transport) string { for _, u := range h.URIs { @@ -180,7 +216,8 @@ func (h *HostData) DefaultURL(transport Transport) string { return "" } -// buildServerData builds the server data for the given server expression. +// buildServerData copies one server's service names, hosts, URL variables, +// transports, and handler arguments for the example templates. func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { hosts := make([]*HostData, 0, len(svr.Hosts)) for _, h := range svr.Hosts { @@ -192,7 +229,7 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { foundVars = make(map[string]struct{}) ) - // collect all the URL variables defined in host expressions + // List each URL variable once even when several hosts use it. for _, h := range hosts { for _, v := range h.Variables { if _, ok := foundVars[v.Name]; ok { @@ -207,6 +244,8 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { transports []*TransportData httpServices []string grpcServices []string + hasHTTP bool + hasJSONRPC bool foundTrans = make(map[Transport]struct{}) ) @@ -214,6 +253,7 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { _, seenHTTP := foundTrans[TransportHTTP] _, seenGRPC := foundTrans[TransportGRPC] if root.API.HTTP.Service(svc) != nil { + hasHTTP = true httpServices = append(httpServices, svc) if !seenHTTP { transports = append(transports, newHTTPTransport()) @@ -222,7 +262,8 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { seenHTTP = true } if root.API.JSONRPC.Service(svc) != nil { - // JSON-RPC implies HTTP transport; ensure HTTP transport exists + hasJSONRPC = true + // JSON-RPC runs over HTTP, so both use the same server listener. if !seenHTTP { transports = append(transports, newHTTPTransport()) foundTrans[TransportHTTP] = struct{}{} @@ -244,26 +285,151 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { transport.Services = grpcServices } } + dir := codegen.SnakeCase(codegen.Goify(svr.Name, true)) sd := &Data{ - Name: svr.Name, - Description: svr.Description, - Services: svr.Services, - Schemes: svr.Schemes(), - Hosts: hosts, - Variables: variables, - Transports: transports, - Dir: codegen.SnakeCase(codegen.Goify(svr.Name, true)), - } - // Precompute handler args for each URI of each host + Name: svr.Name, + Description: svr.Description, + Services: append([]string(nil), svr.Services...), + Schemes: svr.Schemes(), + Hosts: hosts, + Variables: variables, + Transports: transports, + Dir: dir, + serverMainPath: filepath.Join("cmd", dir, "main.go"), + clientMainPath: filepath.Join("cmd", dir+"-cli", "main.go"), + HasHTTP: hasHTTP, + HasJSONRPC: hasJSONRPC, + usageCommands: usageCommands(svr, root), + jsonRPCOnly: jsonRPCOnlyCommands(svr, root), + } + sd.writesEndpointResult, sd.writesStreamResults = clientResultWriters(svr, root) + // Keep the handler argument order while the complete design is still available. for _, h := range sd.Hosts { for _, u := range h.URIs { - u.HandlerArgs = computeHandlerArgsForURI(u, sd, root) + u.HandlerArgs = planHandlerArgsForURI(u, sd, root) } } return sd } -// buildHostData builds the host data for the given host expression. +// clientResultWriters reports which result helpers the server's example +// client calls. Commands that need streamed input are rejected before they +// invoke an endpoint and therefore need neither helper. +func clientResultWriters(server *expr.ServerExpr, root *expr.RootExpr) (endpoint, stream bool) { + addMethod := func(method *expr.MethodExpr, mixedUsesEndpoint bool) { + if method.IsPayloadStreaming() { + return + } + if method.IsResultStreaming() && !(mixedUsesEndpoint && method.HasMixedResults()) { + stream = true + return + } + endpoint = true + } + for _, serviceName := range server.Services { + if service := root.API.HTTP.Service(serviceName); service != nil { + for _, transportEndpoint := range service.HTTPEndpoints { + addMethod(transportEndpoint.MethodExpr, true) + } + } + if service := root.API.JSONRPC.Service(serviceName); service != nil { + for _, transportEndpoint := range service.HTTPEndpoints { + addMethod(transportEndpoint.MethodExpr, false) + } + } + if service := root.API.GRPC.Service(serviceName); service != nil { + for _, transportEndpoint := range service.GRPCEndpoints { + addMethod(transportEndpoint.MethodExpr, false) + } + } + } + return +} + +// usageCommands returns the complete help list for one server. Each transport +// contributes the commands accepted by its generated client. +func usageCommands(server *expr.ServerExpr, root *expr.RootExpr) []string { + var commands []string + for _, serviceName := range server.Services { + if service := root.API.HTTP.Service(serviceName); service != nil { + commands = appendUsageCommand(commands, serviceName, httpEndpointNames(service.HTTPEndpoints)) + } + if service := root.API.JSONRPC.Service(serviceName); service != nil { + commands = appendUsageCommand(commands, serviceName, httpEndpointNames(service.HTTPEndpoints)) + } + if service := root.API.GRPC.Service(serviceName); service != nil { + endpoints := make([]string, len(service.GRPCEndpoints)) + for i, endpoint := range service.GRPCEndpoints { + endpoints[i] = codegen.KebabCase(endpoint.Name()) + } + commands = appendUsageCommand(commands, serviceName, endpoints) + } + } + sort.Strings(commands) + return slices.Compact(commands) +} + +// jsonRPCOnlyCommands returns the service and endpoint pairs handled only by +// the JSON-RPC client. +func jsonRPCOnlyCommands(server *expr.ServerExpr, root *expr.RootExpr) []*jsonRPCServiceData { + var services []*jsonRPCServiceData + for _, serviceName := range server.Services { + jsonRPC := root.API.JSONRPC.Service(serviceName) + if jsonRPC == nil { + continue + } + httpMethods := make(map[string]struct{}) + if httpService := root.API.HTTP.Service(serviceName); httpService != nil { + for _, endpoint := range httpService.HTTPEndpoints { + httpMethods[endpoint.MethodExpr.Name] = struct{}{} + } + } + var endpoints []string + for _, endpoint := range jsonRPC.HTTPEndpoints { + if _, alsoHTTP := httpMethods[endpoint.MethodExpr.Name]; !alsoHTTP { + endpoints = append(endpoints, codegen.KebabCase(endpoint.Name())) + } + } + if len(endpoints) > 0 { + services = append(services, &jsonRPCServiceData{ + Service: codegen.KebabCase(serviceName), + Endpoints: endpoints, + }) + } + } + return services +} + +// httpEndpointNames returns the command-line names for endpoints in design +// order. +func httpEndpointNames(endpoints []*expr.HTTPEndpointExpr) []string { + names := make([]string, len(endpoints)) + for i, endpoint := range endpoints { + names[i] = codegen.KebabCase(endpoint.Name()) + } + return names +} + +// appendUsageCommand adds one client's help entry when it has endpoints. +func appendUsageCommand(commands []string, serviceName string, endpoints []string) []string { + if len(endpoints) == 0 { + return commands + } + var left, right string + if len(endpoints) > 1 { + left, right = "(", ")" + } + return append(commands, fmt.Sprintf( + "%s %s%s%s", + codegen.KebabCase(serviceName), + left, + strings.Join(endpoints, "|"), + right, + )) +} + +// buildHostData copies one host's name, description, URLs, and URL variables +// for the example templates. func buildHostData(host *expr.HostExpr) *HostData { uris := make([]*URIData, len(host.URIs)) for i, uv := range host.URIs { @@ -315,16 +481,15 @@ func buildHostData(host *expr.HostExpr) *HostData { for i, v := range *vars { def := v.Attribute.DefaultValue var values []string + if v.Attribute.Validation != nil && len(v.Attribute.Validation.Values) > 0 { + values = convertToString(v.Attribute.Validation.Values...) + } if def == nil { def = v.Attribute.Validation.Values[0] - // DSL ensures v.Attribute has either a - // default value or an enum validation - values = convertToString(v.Attribute.Validation.Values...) } variables[i] = &VariableData{ Name: v.Name, Description: v.Attribute.Description, - VarName: codegen.Goify(v.Name, false), DefaultValue: convertToString(def)[0], Values: values, } @@ -339,6 +504,38 @@ func buildHostData(host *expr.HostExpr) *HostData { } } +// planMainVariables chooses command-line and Go names that do not collide +// with the flags already emitted by one main program. +func planMainVariables(variables []*VariableData, fixedFlags []string) *mainVariables { + flagScope := codegen.NewNameScope() + localScope := codegen.NewNameScope() + fixed := make(map[string]struct{}, len(fixedFlags)) + for _, flagName := range fixedFlags { + fixed[flagName] = struct{}{} + flagScope.Unique(flagName) + localScope.Unique(codegen.Goify(flagName, false) + "F") + } + planned := &mainVariables{ + all: make([]*mainVariableData, len(variables)), + byName: make(map[string]*mainVariableData, len(variables)), + } + for index, variable := range variables { + preferred := variable.Name + if _, conflicts := fixed[preferred]; conflicts { + preferred = "url-" + preferred + } + flagName := flagScope.Unique(preferred) + value := &mainVariableData{ + VariableData: variable, + FlagName: flagName, + VarName: localScope.Unique(codegen.Goify(flagName, false) + "F"), + } + planned.all[index] = value + planned.byName[variable.Name] = value + } + return planned +} + // convertToString converts primitive type to a string. func convertToString(vals ...any) []string { str := make([]string, len(vals)) @@ -379,12 +576,10 @@ func newGRPCTransport() *TransportData { return &TransportData{Type: TransportGRPC, Name: "gRPC"} } -// computeHandlerArgsForURI returns the ordered handler arguments for the given URI. -// For HTTP URIs that serve both HTTP and JSON-RPC services, the order is: -// - HTTP service endpoints (for services in the HTTP transport list) -// - JSON-RPC service interfaces (in JSONRPC.Services order) -// - JSON-RPC service endpoints (for services not already added as HTTP endpoints) -func computeHandlerArgsForURI(uri *URIData, server *Data, root *expr.RootExpr) []HandlerArg { +// planHandlerArgsForURI lists the services passed to one generated handler. +// HTTP endpoints come first, followed by JSON-RPC services and any remaining +// JSON-RPC endpoints. +func planHandlerArgsForURI(uri *URIData, server *Data, root *expr.RootExpr) []HandlerArg { capHint := len(server.Services) grpcSvcNames := make([]string, 0, capHint) for _, t := range server.Transports { @@ -395,14 +590,15 @@ func computeHandlerArgsForURI(uri *URIData, server *Data, root *expr.RootExpr) [ if uri.Transport.Type == TransportGRPC { out := make([]HandlerArg, 0, len(grpcSvcNames)) for _, name := range grpcSvcNames { - out = append(out, HandlerArg{Endpoint: codegen.Goify(name, false) + "Endpoints"}) + out = append(out, HandlerArg{Service: name, Endpoint: true}) } return out } - var jsonrpcServices []*expr.HTTPServiceExpr - if root.API != nil && root.API.JSONRPC != nil { - jsonrpcServices = root.API.JSONRPC.Services + jsonrpcServices := root.API.JSONRPC.Services + hostedServices := make(map[string]struct{}, len(server.Services)) + for _, name := range server.Services { + hostedServices[name] = struct{}{} } httpSvcSet := make(map[string]struct{}, len(server.Services)) @@ -430,53 +626,32 @@ func computeHandlerArgsForURI(uri *URIData, server *Data, root *expr.RootExpr) [ return false } - // Build set of services that are in $.Services for the template. - // The template data depends on whether there are HTTP services: - // - If there are HTTP services: $.Services = HTTP services only - // - If there are NO HTTP services: $.Services = all JSON-RPC services + // The HTTP helper receives ordinary HTTP endpoints first. servicesInTemplate := make(map[string]struct{}) - hasHTTPServices := false - if root.API != nil && root.API.HTTP != nil && len(root.API.HTTP.Services) > 0 { - hasHTTPServices = true - for _, hs := range root.API.HTTP.Services { - if hs.ServiceExpr != nil { - servicesInTemplate[hs.ServiceExpr.Name] = struct{}{} - } - } - } - // If no HTTP services, JSON-RPC services populate $.Services - if !hasHTTPServices && root.API != nil && root.API.JSONRPC != nil { - for _, js := range root.API.JSONRPC.Services { - if js.ServiceExpr != nil { - servicesInTemplate[js.ServiceExpr.Name] = struct{}{} - } - } + for _, hs := range root.API.HTTP.Services { + servicesInTemplate[hs.ServiceExpr.Name] = struct{}{} } addedEndpoints := make(map[string]bool, len(server.Services)) - // Step 1: Add endpoint pointers for services in server.Services that are also in $.Services. - // This matches the template's first loop: {{ range $.Services }}{{ if .Service.Methods }} - // where $.Services includes both HTTP and JSON-RPC services. + // Add endpoint variables for the services passed first. for _, svcName := range server.Services { if _, inTemplate := servicesInTemplate[svcName]; inTemplate && serviceHasHandlers(svcName) { - out = append(out, HandlerArg{Endpoint: codegen.Goify(svcName, false) + "Endpoints"}) + out = append(out, HandlerArg{Service: svcName, Endpoint: true}) addedEndpoints[svcName] = true } } - // Step 2: For each JSON-RPC service, add service interface, then endpoint (if not HTTP). - // This matches the template's second loop: {{ range $.JSONRPCServices }} - // where each iteration adds the service, checks if it's in $.Services, and conditionally - // adds the endpoint - all in the same iteration (not separate loops). + // Add each JSON-RPC service variable followed by its endpoint variable when + // that endpoint was not already added above. for _, jsvc := range jsonrpcServices { name := jsvc.ServiceExpr.Name - // Add service interface - out = append(out, HandlerArg{Service: codegen.Goify(name, false) + "Svc"}) - // Add endpoint if this service doesn't have HTTP transport - // (i.e., wasn't added in Step 1) + if _, hosted := hostedServices[name]; !hosted { + continue + } + out = append(out, HandlerArg{Service: name}) if !addedEndpoints[name] && serviceHasHandlers(name) { - out = append(out, HandlerArg{Endpoint: codegen.Goify(name, false) + "Endpoints"}) + out = append(out, HandlerArg{Service: name, Endpoint: true}) addedEndpoints[name] = true } } diff --git a/codegen/example/server_data_test.go b/codegen/example/server_data_test.go index faf015c837..21861ff28c 100644 --- a/codegen/example/server_data_test.go +++ b/codegen/example/server_data_test.go @@ -3,6 +3,8 @@ package example import ( "testing" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/expr" ) @@ -24,6 +26,13 @@ func TestComputeHandlerArgsForURI_JSONRPCOrdering(t *testing.T) { }, HTTPEndpoints: []*expr.HTTPEndpointExpr{{MethodExpr: mcpMethod}}, } + jsonrpcUnhosted := &expr.HTTPServiceExpr{ + ServiceExpr: &expr.ServiceExpr{ + Name: "unhosted", + Methods: []*expr.MethodExpr{{Name: "Ignore"}}, + }, + HTTPEndpoints: []*expr.HTTPEndpointExpr{{MethodExpr: &expr.MethodExpr{Name: "Ignore"}}}, + } root := &expr.RootExpr{ API: &expr.APIExpr{ HTTP: &expr.HTTPExpr{ @@ -31,13 +40,14 @@ func TestComputeHandlerArgsForURI_JSONRPCOrdering(t *testing.T) { }, JSONRPC: &expr.JSONRPCExpr{ HTTPExpr: expr.HTTPExpr{ - Services: []*expr.HTTPServiceExpr{jsonrpcOrchestrator, jsonrpcMCPAssistant}, + Services: []*expr.HTTPServiceExpr{jsonrpcOrchestrator, jsonrpcMCPAssistant, jsonrpcUnhosted}, }, }, }, Services: []*expr.ServiceExpr{ {Name: "orchestrator", Methods: []*expr.MethodExpr{method}}, {Name: "mcp_assistant", Methods: []*expr.MethodExpr{mcpMethod}}, + {Name: "unhosted", Methods: []*expr.MethodExpr{{Name: "Ignore"}}}, }, } server := &Data{ @@ -48,13 +58,13 @@ func TestComputeHandlerArgsForURI_JSONRPCOrdering(t *testing.T) { } uri := &URIData{Transport: &TransportData{Type: TransportHTTP}} - args := computeHandlerArgsForURI(uri, server, root) + args := planHandlerArgsForURI(uri, server, root) want := []HandlerArg{ - {Endpoint: "orchestratorEndpoints"}, - {Service: "orchestratorSvc"}, - {Service: "mcpAssistantSvc"}, - {Endpoint: "mcpAssistantEndpoints"}, + {Service: "orchestrator", Endpoint: true}, + {Service: "orchestrator"}, + {Service: "mcp_assistant"}, + {Service: "mcp_assistant", Endpoint: true}, } if len(args) != len(want) { t.Fatalf("expected %d handler args, got %d (%v)", len(want), len(args), args) @@ -65,3 +75,42 @@ func TestComputeHandlerArgsForURI_JSONRPCOrdering(t *testing.T) { } } } + +// TestPlanHandlerArgsForJSONRPCOnlyServer checks that the generated main and +// HTTP helper can use the same service-by-service argument order. +func TestPlanHandlerArgsForJSONRPCOnlyServer(t *testing.T) { + firstMethod := &expr.MethodExpr{Name: "First"} + secondMethod := &expr.MethodExpr{Name: "Second"} + first := &expr.ServiceExpr{Name: "first", Methods: []*expr.MethodExpr{firstMethod}} + second := &expr.ServiceExpr{Name: "second", Methods: []*expr.MethodExpr{secondMethod}} + root := &expr.RootExpr{ + API: &expr.APIExpr{ + HTTP: &expr.HTTPExpr{}, + JSONRPC: &expr.JSONRPCExpr{HTTPExpr: expr.HTTPExpr{Services: []*expr.HTTPServiceExpr{ + { + ServiceExpr: first, + HTTPEndpoints: []*expr.HTTPEndpointExpr{{MethodExpr: firstMethod}}, + }, + { + ServiceExpr: second, + HTTPEndpoints: []*expr.HTTPEndpointExpr{{MethodExpr: secondMethod}}, + }, + }}}, + }, + Services: []*expr.ServiceExpr{first, second}, + } + server := &Data{ + Services: []string{"first", "second"}, + Transports: []*TransportData{{ + Type: TransportHTTP, + }}, + } + uri := &URIData{Transport: &TransportData{Type: TransportHTTP}} + + require.Equal(t, []HandlerArg{ + {Service: "first"}, + {Service: "first", Endpoint: true}, + {Service: "second"}, + {Service: "second", Endpoint: true}, + }, planHandlerArgsForURI(uri, server, root)) +} diff --git a/codegen/example/templates/client_end.go.tpl b/codegen/example/templates/client_end.go.tpl index 21b99bc20b..6e78d3386a 100644 --- a/codegen/example/templates/client_end.go.tpl +++ b/codegen/example/templates/client_end.go.tpl @@ -1,13 +1,48 @@ +} - data, err := endpoint(context.Background(), payload) +{{- if .WritesEndpointResult }} +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err + } + return writeJSON(stdout, data) +} +{{- end }} + +{{- if .WritesStreamResults }} +// writeStreamResults writes each server result until the server ends the stream. +func writeStreamResults[T any](ctx context.Context, stdout io.Writer, recv func(context.Context) (T, error)) error { + for { + data, err := recv(ctx) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("receive result: %w", err) + } + if err := writeJSON(stdout, data); err != nil { + return err + } } +} +{{- end }} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +{{- if or .WritesEndpointResult .WritesStreamResults }} +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) } + return nil } +{{- end }} diff --git a/codegen/example/templates/client_endpoint_init.go.tpl b/codegen/example/templates/client_endpoint_init.go.tpl index 02353b8c76..b1ef3de424 100644 --- a/codegen/example/templates/client_endpoint_init.go.tpl +++ b/codegen/example/templates/client_endpoint_init.go.tpl @@ -1,7 +1,5 @@ var ( - endpoint goa.Endpoint - payload any err error ) { @@ -11,18 +9,29 @@ {{- if and (eq $t.Type "http") $.HasJSONRPC }} {{- if $.HasHTTP }} if *jsonrpcF || *jF { - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) } else { - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) - if err != nil && strings.HasPrefix(err.Error(), "unknown") { - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + switch flag.Arg(0) { + {{- range $.JSONRPCOnly }} + case {{ printf "%q" .Service }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + case {{ printf "%q" . }}: + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + {{- end }} + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + } + {{- end }} + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) } } {{- else }} - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) {{- end }} {{- else }} - endpoint, payload, err = do{{ toUpper $t.Name }}(scheme, host, timeout, debug) + err = do{{ toUpper $t.Name }}(context.Background(), scheme, host, timeout, debug, os.Stdout) {{- end }} {{- end }} default: diff --git a/codegen/example/templates/client_start.go.tpl b/codegen/example/templates/client_start.go.tpl index b668793bdc..2054095416 100644 --- a/codegen/example/templates/client_start.go.tpl +++ b/codegen/example/templates/client_start.go.tpl @@ -4,7 +4,7 @@ func main() { hostF = flag.String("host", {{ printf "%q" .Server.DefaultHost.Name }}, "Server host (valid values: {{ (join .Server.AvailableHosts ", ") }})") addrF = flag.String("url", "", "URL to service host") {{- range .Server.Variables }} - {{ .VarName }}F = flag.String({{ printf "%q" .Name }}, {{ printf "%q" .DefaultValue }}, {{ printf "%q" .Description }}) + {{ .VarName }} = flag.String({{ printf "%q" .FlagName }}, {{ printf "%q" .DefaultValue }}, {{ printf "%q" .Description }}) {{- end }} {{- if and .HasJSONRPC .HasHTTP }} jsonrpcF = flag.Bool("jsonrpc", false, "Force JSON-RPC transport") diff --git a/codegen/example/templates/client_usage.go.tpl b/codegen/example/templates/client_usage.go.tpl index e5ee17de74..3151b319c3 100644 --- a/codegen/example/templates/client_usage.go.tpl +++ b/codegen/example/templates/client_usage.go.tpl @@ -1,21 +1,15 @@ func usage() { - var usageCommands []string -{{- range .Server.Transports }} - {{- if and (eq .Type "http") $.HasHTTP }} - usageCommands = append(usageCommands, {{ .Type }}UsageCommands()...) + usageCommands := []string{ + {{- range .UsageCommands }} + {{ printf "%q" . }}, {{- end }} -{{- end }} -{{- if .HasJSONRPC }} - usageCommands = append(usageCommands, jsonrpcUsageCommands()...) -{{- end }} - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + } fmt.Fprintf(os.Stderr, `%s is a command line client for the {{ .APIName }} API. Usage: - %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v]{{ range .Server.Variables }}[-{{ .Name }} {{ toUpper .Name }}]{{ end }} SERVICE ENDPOINT [flags] + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v]{{ range .Server.Variables }}[-{{ .FlagName }} {{ toUpper .Name }}]{{ end }} SERVICE ENDPOINT [flags] -host HOST: server host ({{ .Server.DefaultHost.Name }}). valid values: {{ (join .Server.AvailableHosts ", ") }} -url URL: specify service URL overriding host URL (http://localhost:8080) @@ -25,7 +19,7 @@ Usage: -timeout: maximum number of seconds to wait for response (30) -verbose|-v: print request and response details (false) {{- range .Server.Variables }} - -{{ .Name }}: {{ .Description }} ({{ .DefaultValue }}) + -{{ .FlagName }}: {{ .Description }} ({{ .DefaultValue }}) {{- end }} Commands: diff --git a/codegen/example/templates/client_var_init.go.tpl b/codegen/example/templates/client_var_init.go.tpl index 65f0430c95..dae738f250 100644 --- a/codegen/example/templates/client_var_init.go.tpl +++ b/codegen/example/templates/client_var_init.go.tpl @@ -10,24 +10,17 @@ var ( switch *hostF { {{- range $h := .Server.Hosts }} case {{ printf "%q" $h.Name }}: - addr = {{ printf "%q" ($h.DefaultURL $.Server.DefaultTransport.Type) }} + addr = {{ printf "%q" ($h.DefaultURL $.Server.DefaultTransport.Type) }} {{- range $h.Variables }} {{- if .Values }} - var {{ .VarName }}Seen bool - { - for _, v := range []string{ {{ range $v := .Values }}"{{ $v }}",{{ end }} } { - if v == *{{ .VarName }}F { - {{ .VarName }}Seen = true - break - } - } - } - if !{{ .VarName }}Seen { - fmt.Fprintf(os.Stderr, "invalid value for URL '{{ .Name }}' variable: %q (valid values: {{ join .Values "," }})\n", *{{ .VarName }}F) + switch *{{ .VarName }} { + case {{ range $index, $value := .Values }}{{ if $index }}, {{ end }}{{ printf "%q" $value }}{{ end }}: + default: + fmt.Fprintf(os.Stderr, "invalid value for URL '{{ .Name }}' variable: %q (valid values: {{ join .Values "," }})\n", *{{ .VarName }}) os.Exit(1) } {{- end }} - addr = strings.ReplaceAll(addr, "{{ printf "{%s}" .Name }}", *{{ .VarName }}F) + addr = strings.ReplaceAll(addr, "{{ printf "{%s}" .Name }}", *{{ .VarName }}) {{- end }} {{- end }} default: diff --git a/codegen/example/templates/server_endpoints.go.tpl b/codegen/example/templates/server_endpoints.go.tpl index e0587e27ee..8a47b2a743 100644 --- a/codegen/example/templates/server_endpoints.go.tpl +++ b/codegen/example/templates/server_endpoints.go.tpl @@ -1,19 +1,19 @@ -{{- if mustInitServices .Services }} +{{- if .HasServices }} {{ comment "Wrap the services in endpoints that can be invoked from other services potentially running in different processes." }} var ( {{- range .Services }} - {{- if .Methods }} - {{ .VarName }}Endpoints *{{ .PkgName }}.Endpoints + {{- if .HasMethods }} + {{ .EndpointsVar }} *{{ .PkgName }}.{{ .EndpointsDeclaration.Name }} {{- end }} {{- end }} ) { {{- range .Services }} - {{- if .Methods }} - {{ .VarName }}Endpoints = {{ .PkgName }}.NewEndpoints({{ .VarName }}Svc{{ if .ServerInterceptors }}, {{ .VarName }}Interceptors{{ end }}) - {{ .VarName }}Endpoints.Use(debug.LogPayloads()) - {{ .VarName }}Endpoints.Use(log.Endpoint) + {{- if .HasMethods }} + {{ .EndpointsVar }} = {{ .PkgName }}.{{ .NewEndpointsDeclaration.Name }}({{ .ServiceVar }}{{ if .HasServerInterceptors }}, {{ .InterceptorsVar }}{{ end }}) + {{ .EndpointsVar }}.Use(debug.LogPayloads()) + {{ .EndpointsVar }}.Use(log.Endpoint) {{- end }} {{- end }} } diff --git a/codegen/example/templates/server_handler.go.tpl b/codegen/example/templates/server_handler.go.tpl index 13d2f03c09..93427fff80 100644 --- a/codegen/example/templates/server_handler.go.tpl +++ b/codegen/example/templates/server_handler.go.tpl @@ -10,20 +10,13 @@ addr := {{ printf "%q" $u.URL }} {{- range $h.Variables }} {{- if .Values }} - var {{ .VarName }}Seen bool - { - for _, v := range []string{ {{ range $v := .Values }}"{{ $v }}",{{ end }} } { - if v == *{{ .VarName }}F { - {{ .VarName }}Seen = true - break - } - } - } - if !{{ .VarName }}Seen { - log.Fatal(ctx, fmt.Errorf("invalid value for URL '{{ .Name }}' variable: %q (valid values: {{ join .Values "," }})\n", *{{ .VarName }}F)) + switch *{{ .VarName }} { + case {{ range $index, $value := .Values }}{{ if $index }}, {{ end }}{{ printf "%q" $value }}{{ end }}: + default: + log.Fatal(ctx, fmt.Errorf("invalid value for URL '{{ .Name }}' variable: %q (valid values: {{ join .Values "," }})\n", *{{ .VarName }})) } {{- end }} - addr = strings.ReplaceAll(addr, "{{ printf "{%s}" .Name }}", *{{ .VarName }}F) + addr = strings.ReplaceAll(addr, "{{ printf "{%s}" .Name }}", *{{ .VarName }}) {{- end }} u, err := url.Parse(addr) if err != nil { @@ -44,7 +37,7 @@ } else if u.Port() == "" { u.Host = net.JoinHostPort(u.Host, "{{ $u.Port }}") } - handle{{ toUpper $u.Transport.Name }}Server(ctx, u{{- range $u.HandlerArgs }}{{- if .Endpoint }}, {{ .Endpoint }}{{- end }}{{- if .Service }}, {{ .Service }}{{- end }}{{- end }}, &wg, errc, *dbgF) + handle{{ toUpper $u.Transport.Name }}Server(ctx, u{{- range $u.HandlerArgs }}, {{ .Variable }}{{- end }}, &wg, errc, *dbgF) } {{- end }} {{ end }} diff --git a/codegen/example/templates/server_interceptors.go.tpl b/codegen/example/templates/server_interceptors.go.tpl index cdb973dcce..d1d23b0c4f 100644 --- a/codegen/example/templates/server_interceptors.go.tpl +++ b/codegen/example/templates/server_interceptors.go.tpl @@ -1,17 +1,17 @@ -{{- if mustInitServices .Services }} +{{- if .HasServices }} {{- if .HasInterceptors }} {{ comment "Initialize the interceptors." }} var ( {{- range .Services }} - {{- if and .Methods .ServerInterceptors }} - {{ .VarName }}Interceptors {{ .PkgName }}.ServerInterceptors + {{- if and .HasMethods .HasServerInterceptors }} + {{ .InterceptorsVar }} {{ .PkgName }}.{{ .ServerInterceptorsDeclaration.Name }} {{- end }} {{- end }} ) { {{- range .Services }} - {{- if and .Methods .ServerInterceptors }} - {{ .VarName }}Interceptors = {{ $.InterPkg }}.New{{ .StructName }}ServerInterceptors() + {{- if and .HasMethods .HasServerInterceptors }} + {{ .InterceptorsVar }} = {{ $.InterPkg }}.{{ .ExampleInterceptorsConstructor.Name }}() {{- end }} {{- end }} } diff --git a/codegen/example/templates/server_logger.go.tpl b/codegen/example/templates/server_logger.go.tpl index 642c082687..89569a2665 100644 --- a/codegen/example/templates/server_logger.go.tpl +++ b/codegen/example/templates/server_logger.go.tpl @@ -10,6 +10,6 @@ ctx = log.Context(ctx, log.WithDebug()) log.Debugf(ctx, "debug logs enabled") } -{{- if .Server.Transports }} - log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) +{{- range .Server.Transports }} + log.Print(ctx, log.KV{K: "{{ .Type }}-port", V: *{{ .Type }}PortF}) {{- end }} diff --git a/codegen/example/templates/server_services.go.tpl b/codegen/example/templates/server_services.go.tpl index 2de5109753..231a040aa9 100644 --- a/codegen/example/templates/server_services.go.tpl +++ b/codegen/example/templates/server_services.go.tpl @@ -1,17 +1,17 @@ -{{- if mustInitServices .Services }} +{{- if .HasServices }} {{ comment "Initialize the services." }} var ( {{- range .Services }} - {{- if .Methods }} - {{ .VarName }}Svc {{ .PkgName }}.Service + {{- if .HasMethods }} + {{ .ServiceVar }} {{ .PkgName }}.{{ .ServiceDeclaration.Name }} {{- end }} {{- end }} ) { {{- range .Services }} - {{- if .Methods }} - {{ .VarName }}Svc = {{ $.APIPkg }}.New{{ .StructName }}() + {{- if .HasMethods }} + {{ .ServiceVar }} = {{ $.APIPkg }}.{{ .ExampleConstructorDeclaration.Name }}() {{- end }} {{- end }} } diff --git a/codegen/example/templates/server_start.go.tpl b/codegen/example/templates/server_start.go.tpl index 0e722ae8eb..cf246267e9 100644 --- a/codegen/example/templates/server_start.go.tpl +++ b/codegen/example/templates/server_start.go.tpl @@ -8,7 +8,7 @@ func main() { {{ .Type }}PortF = flag.String("{{ .Type }}-port", "", "{{ .Name }} port (overrides host {{ .Name }} port specified in service design)") {{- end }} {{- range .Server.Variables }} - {{ .VarName }}F = flag.String({{ printf "%q" .Name }}, {{ printf "%q" .DefaultValue }}, "{{ .Description }}{{ if .Values }} (valid values: {{ join .Values ", " }}){{ end }}") + {{ .VarName }} = flag.String({{ printf "%q" .FlagName }}, {{ printf "%q" .DefaultValue }}, "{{ .Description }}{{ if .Values }} (valid values: {{ join .Values ", " }}){{ end }}") {{- end }} secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") dbgF = flag.Bool("debug", false, "Log request and response bodies") diff --git a/codegen/example/testdata/client-input-stream.golden b/codegen/example/testdata/client-input-stream.golden new file mode 100644 index 0000000000..8f1dc1f2c8 --- /dev/null +++ b/codegen/example/testdata/client-input-stream.golden @@ -0,0 +1,101 @@ +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "http://localhost:80" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "http", "https": + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + case "grpc", "grpcs": + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +func usage() { + usageCommands := []string{ + "events upload", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (http://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(httpUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-mixed-results.golden b/codegen/example/testdata/client-mixed-results.golden new file mode 100644 index 0000000000..43c77a70a5 --- /dev/null +++ b/codegen/example/testdata/client-mixed-results.golden @@ -0,0 +1,123 @@ +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "http://localhost:80" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "http", "https": + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + return writeJSON(stdout, data) +} + +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil +} + +func usage() { + usageCommands := []string{ + "events create", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (http://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(httpUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-no-server.golden b/codegen/example/testdata/client-no-server.golden index 4bed82edee..3b8e5060e3 100644 --- a/codegen/example/testdata/client-no-server.golden +++ b/codegen/example/testdata/client-no-server.golden @@ -45,16 +45,14 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) case "grpc", "grpcs": - endpoint, payload, err = doGRPC(scheme, host, timeout, debug) + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) os.Exit(1) @@ -69,23 +67,36 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. Usage: diff --git a/codegen/example/testdata/client-server-stream.golden b/codegen/example/testdata/client-server-stream.golden new file mode 100644 index 0000000000..531f60a75b --- /dev/null +++ b/codegen/example/testdata/client-server-stream.golden @@ -0,0 +1,130 @@ +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "grpc://localhost:8080" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "grpc", "grpcs": + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +// writeStreamResults writes each server result until the server ends the stream. +func writeStreamResults[T any](ctx context.Context, stdout io.Writer, recv func(context.Context) (T, error)) error { + for { + data, err := recv(ctx) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("receive result: %w", err) + } + if err := writeJSON(stdout, data); err != nil { + return err + } + } +} + +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil +} + +func usage() { + usageCommands := []string{ + "events watch", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (http://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(grpcUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-single-server-multiple-hosts-with-variables.golden b/codegen/example/testdata/client-single-server-multiple-hosts-with-variables.golden index bb0df699fc..6fcdcb5a35 100644 --- a/codegen/example/testdata/client-single-server-multiple-hosts-with-variables.golden +++ b/codegen/example/testdata/client-single-server-multiple-hosts-with-variables.golden @@ -24,16 +24,9 @@ func main() { switch *hostF { case "dev": addr = "http://example-{version}:8090" - var versionSeen bool - { - for _, v := range []string{"v1", "v2"} { - if v == *versionF { - versionSeen = true - break - } - } - } - if !versionSeen { + switch *versionF { + case "v1", "v2": + default: fmt.Fprintf(os.Stderr, "invalid value for URL 'version' variable: %q (valid values: v1,v2)\n", *versionF) os.Exit(1) } @@ -66,14 +59,12 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: http|https)\n", scheme) os.Exit(1) @@ -88,23 +79,36 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the SingleServerMultipleHostsWithVariables API. Usage: diff --git a/codegen/example/testdata/client-single-server-multiple-hosts.golden b/codegen/example/testdata/client-single-server-multiple-hosts.golden index efb2085ef0..e02e11766e 100644 --- a/codegen/example/testdata/client-single-server-multiple-hosts.golden +++ b/codegen/example/testdata/client-single-server-multiple-hosts.golden @@ -47,14 +47,12 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: http|https)\n", scheme) os.Exit(1) @@ -69,23 +67,36 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the SingleServerMultipleHosts API. Usage: diff --git a/codegen/example/testdata/client-single-server-single-host-with-variables.golden b/codegen/example/testdata/client-single-server-single-host-with-variables.golden index 4fd6755bc6..00d2c111c3 100644 --- a/codegen/example/testdata/client-single-server-single-host-with-variables.golden +++ b/codegen/example/testdata/client-single-server-single-host-with-variables.golden @@ -63,14 +63,12 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: http|https)\n", scheme) os.Exit(1) @@ -85,23 +83,36 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the SingleServerSingleHostWithVariables API. Usage: diff --git a/codegen/example/testdata/client-single-server-single-host.golden b/codegen/example/testdata/client-single-server-single-host.golden index fadb54b2b9..88d83a97e1 100644 --- a/codegen/example/testdata/client-single-server-single-host.golden +++ b/codegen/example/testdata/client-single-server-single-host.golden @@ -45,16 +45,14 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) case "grpc", "grpcs": - endpoint, payload, err = doGRPC(scheme, host, timeout, debug) + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http|https)\n", scheme) os.Exit(1) @@ -69,23 +67,36 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the SingleServerSingleHost API. Usage: diff --git a/codegen/example/testdata/server-no-server.golden b/codegen/example/testdata/server-no-server.golden index 8d6ed8fba7..55490aa714 100644 --- a/codegen/example/testdata/server-no-server.golden +++ b/codegen/example/testdata/server-no-server.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-same-api-service-name.golden b/codegen/example/testdata/server-same-api-service-name.golden index 30462fadc0..db02a0b490 100644 --- a/codegen/example/testdata/server-same-api-service-name.golden +++ b/codegen/example/testdata/server-same-api-service-name.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-sercice-for-only-grpc.golden b/codegen/example/testdata/server-sercice-for-only-grpc.golden index 37d920f66a..1e62a57815 100644 --- a/codegen/example/testdata/server-sercice-for-only-grpc.golden +++ b/codegen/example/testdata/server-sercice-for-only-grpc.golden @@ -20,7 +20,7 @@ func main() { ctx = log.Context(ctx, log.WithDebug()) log.Debugf(ctx, "debug logs enabled") } - log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-server-hosting-multiple-services.golden b/codegen/example/testdata/server-server-hosting-multiple-services.golden index 50ff33ea91..ac3f603fae 100644 --- a/codegen/example/testdata/server-server-hosting-multiple-services.golden +++ b/codegen/example/testdata/server-server-hosting-multiple-services.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-server-hosting-service-subset.golden b/codegen/example/testdata/server-server-hosting-service-subset.golden index ee3a68a010..78071cfbcd 100644 --- a/codegen/example/testdata/server-server-hosting-service-subset.golden +++ b/codegen/example/testdata/server-server-hosting-service-subset.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-service-for-http-and-part-of-grpc.golden b/codegen/example/testdata/server-service-for-http-and-part-of-grpc.golden index 9d052dacb3..2639ec0d88 100644 --- a/codegen/example/testdata/server-service-for-http-and-part-of-grpc.golden +++ b/codegen/example/testdata/server-service-for-http-and-part-of-grpc.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-service-name-with-spaces.golden b/codegen/example/testdata/server-service-name-with-spaces.golden index cd3eeee20e..384db71363 100644 --- a/codegen/example/testdata/server-service-name-with-spaces.golden +++ b/codegen/example/testdata/server-service-name-with-spaces.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-single-server-multiple-hosts-with-variables.golden b/codegen/example/testdata/server-single-server-multiple-hosts-with-variables.golden index dd0fea4a7d..d10843b2e1 100644 --- a/codegen/example/testdata/server-single-server-multiple-hosts-with-variables.golden +++ b/codegen/example/testdata/server-single-server-multiple-hosts-with-variables.golden @@ -2,14 +2,14 @@ func main() { // Define command line flags, add any other flag required to configure the // service. var ( - hostF = flag.String("host", "dev", "Server host (valid values: dev, stage)") - domainF = flag.String("domain", "", "Host domain name (overrides host domain specified in service design)") - httpPortF = flag.String("http-port", "", "HTTP port (overrides host HTTP port specified in service design)") - versionF = flag.String("version", "v1", "Version (valid values: v1, v2)") - domainF = flag.String("domain", "test", "Domain") - portF = flag.String("port", "8080", "Port") - secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") - dbgF = flag.Bool("debug", false, "Log request and response bodies") + hostF = flag.String("host", "dev", "Server host (valid values: dev, stage)") + domainF = flag.String("domain", "", "Host domain name (overrides host domain specified in service design)") + httpPortF = flag.String("http-port", "", "HTTP port (overrides host HTTP port specified in service design)") + versionF = flag.String("version", "v1", "Version (valid values: v1, v2)") + urlDomainF = flag.String("url-domain", "test", "Domain") + portF = flag.String("port", "8080", "Port") + secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") + dbgF = flag.Bool("debug", false, "Log request and response bodies") ) flag.Parse() @@ -64,16 +64,9 @@ func main() { case "dev": { addr := "http://example-{version}:8090" - var versionSeen bool - { - for _, v := range []string{"v1", "v2"} { - if v == *versionF { - versionSeen = true - break - } - } - } - if !versionSeen { + switch *versionF { + case "v1", "v2": + default: log.Fatal(ctx, fmt.Errorf("invalid value for URL 'version' variable: %q (valid values: v1,v2)\n", *versionF)) } addr = strings.ReplaceAll(addr, "{version}", *versionF) @@ -102,7 +95,7 @@ func main() { case "stage": { addr := "https://example-{domain}:{port}" - addr = strings.ReplaceAll(addr, "{domain}", *domainF) + addr = strings.ReplaceAll(addr, "{domain}", *urlDomainF) addr = strings.ReplaceAll(addr, "{port}", *portF) u, err := url.Parse(addr) if err != nil { diff --git a/codegen/example/testdata/server-single-server-single-host.golden b/codegen/example/testdata/server-single-server-single-host.golden index 57c0d3f245..f7847d537a 100644 --- a/codegen/example/testdata/server-single-server-single-host.golden +++ b/codegen/example/testdata/server-single-server-single-host.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/funcs.go b/codegen/funcs.go index e42762ecbe..cf6f334815 100644 --- a/codegen/funcs.go +++ b/codegen/funcs.go @@ -191,7 +191,6 @@ func camelCaseUncached(name string, firstUpper, acronym bool) string { // advance to next word w = i } - return string(runes) } diff --git a/codegen/funcs_test.go b/codegen/funcs_test.go index 1fd4152d79..a58aac5111 100644 --- a/codegen/funcs_test.go +++ b/codegen/funcs_test.go @@ -72,6 +72,56 @@ func TestCamelCase(t *testing.T) { } } +func TestProtobufNames(t *testing.T) { + tests := []struct { + name string + source string + want string + }{ + {name: "empty", want: "Val"}, + {name: "leading digits", source: "123_message", want: "_123Message"}, + {name: "acronym", source: "api_message", want: "APIMessage"}, + {name: "mixed Unicode", source: "café_message", want: "CafMessage"}, + {name: "only Unicode", source: "東京", want: "Val"}, + {name: "field keyword is a legal declaration", source: "string", want: "String"}, + {name: "invalid characters", source: "---", want: "Val"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := ProtobufName(test.source) + if actual != test.want { + t.Errorf("got %q, expected %q", actual, test.want) + } + }) + } +} + +func TestProtobufFieldNames(t *testing.T) { + tests := []struct { + name string + source string + want string + }{ + {name: "empty", want: "val"}, + {name: "leading digits", source: "123Field", want: "_123_field"}, + {name: "acronym", source: "HTTPServer", want: "http_server"}, + {name: "mixed Unicode", source: "caféField", want: "caf_field"}, + {name: "only Unicode", source: "東京", want: "val"}, + {name: "reserved word", source: "string", want: "string_"}, + {name: "invalid characters", source: "---", want: "val"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := ProtobufFieldName(test.source) + if actual != test.want { + t.Errorf("got %q, expected %q", actual, test.want) + } + }) + } +} + func TestKebabCase(t *testing.T) { cases := map[string]struct { str string diff --git a/codegen/generated_types.go b/codegen/generated_types.go new file mode 100644 index 0000000000..9722e88d06 --- /dev/null +++ b/codegen/generated_types.go @@ -0,0 +1,889 @@ +// This file chooses every package-level Go name before source files are +// written. Generators record the names they need, then use the chosen names +// when writing code. +package codegen + +import ( + "fmt" + "reflect" + "slices" + "strings" + + "goa.design/goa/v3/expr" +) + +type ( + // GeneratedPackage stores declarations, final names, and the output location + // for one generated Go package. + GeneratedPackage struct { + claim string + path string + outputDir string + scope *NameScope + names []*NameDeclaration + exactNames map[string]*NameDeclaration + nameBindings map[string]*NameDeclaration + importPlan *importAliasPlan + imports map[string]importAliasBinding + userTypes map[expr.UserType]*TypeDeclaration + typeBindings map[expr.UserType]*TypeDeclaration + derivedTypes map[DerivedTypeID]*TypeDeclaration + unions map[UnionTypeID]*unionDeclaration + frozen bool + } + + // DerivedTypeID identifies a type Goa generated from a source type, such as a + // view, method payload, or method result. + DerivedTypeID struct { + origin expr.UserType + kind derivedTypeKind + } + + // MethodTypeIdentity records the API, Go wrapper name, whether it holds a + // payload or result, the key used to repeat its examples, and the source type + // for one service method. + MethodTypeIdentity struct { + api string + name string + kind derivedTypeKind + exampleIdentity expr.ExampleIdentity + origin expr.UserType + } + + // TypeDeclaration stores the final name and package path of one generated Go + // type. + TypeDeclaration struct { + declaration *NameDeclaration + } + + // UnionDeclaration stores the generated union type name and its kind type + // name. + UnionDeclaration struct { + declaration *NameDeclaration + kindDeclaration *NameDeclaration + } + + // UnionBranchDeclaration stores the constant, constructor, and optional type + // generated for one union branch. + UnionBranchDeclaration struct { + kindDeclaration *NameDeclaration + constructorDeclaration *NameDeclaration + branchType *TypeDeclaration + } + + // unionDeclaration stores a union expression and the names generated for the + // union and each branch. + unionDeclaration struct { + union *expr.Union + declaration *UnionDeclaration + branches map[unionBranchID]*UnionBranchDeclaration + } + + // unionBranchID selects a generated union branch by its design name. + unionBranchID struct { + name string + } + + // derivedTypeKind records whether Goa is generating a view, viewed result, + // method payload, or method result. + derivedTypeKind uint + + // derivedTypeOrder sorts generated view types from copied strings and numbers + // so pointer addresses and visit order cannot change their suffixes. + derivedTypeOrder struct { + kind derivedTypeKind + name string + sourceName string + sourceID string + api string + } + + // unionNameOrder sorts the type, kind, branch type, constant, and constructor + // generated for a union. + unionNameOrder struct { + union UnionTypeID + role unionNameRole + branch string + } + + // unionNameRole records whether a name belongs to the union type, kind type, + // branch type, branch constant, or constructor. + unionNameRole uint8 +) + +const ( + projectedTypeKind derivedTypeKind = iota + 1 + viewedResultTypeKind + methodPayloadTypeKind + methodStreamingPayloadTypeKind + methodResultTypeKind + methodStreamingResultTypeKind +) + +const ( + unionTypeNameRole unionNameRole = iota + 1 + unionKindNameRole + unionBranchTypeNameRole + unionBranchKindNameRole + unionBranchConstructorNameRole +) + +// NewProjectedTypeID returns the key used to find the view-specific copy of +// source whose fields use pointers in the generated service views package. +func NewProjectedTypeID(source expr.UserType) DerivedTypeID { + return newDerivedTypeID(source, projectedTypeKind) +} + +// NewViewedResultTypeID returns the key used to find the viewed-result wrapper +// generated from source in a service views package. +func NewViewedResultTypeID(source expr.UserType) DerivedTypeID { + return newDerivedTypeID(source, viewedResultTypeKind) +} + +// Name returns the Go wrapper name assigned while preparing the method. +func (i MethodTypeIdentity) Name() string { + return i.name +} + +// UID returns the stable example key stored when the method value was prepared. +func (i MethodTypeIdentity) UID() string { + return "generated:" + i.exampleIdentity.Seed() +} + +// Name returns the Go type name without a package qualifier. It panics until +// Generation.Freeze chooses every declaration name because another +// declaration may still change this one. +func (d *TypeDeclaration) Name() string { + return d.declaration.Name() +} + +// PackagePath returns the import path of the package that declares the type. +func (d *TypeDeclaration) PackagePath() string { + return d.declaration.packagePath() +} + +// Declaration returns the NameDeclaration used for this generated type. +func (d *TypeDeclaration) Declaration() *NameDeclaration { + return d.declaration +} + +// Name returns the Go union type name without a package qualifier. It panics +// until Generation.Freeze chooses every declaration name. +func (d *UnionDeclaration) Name() string { + return d.declaration.Name() +} + +// KindName returns the Go union kind type name without a package qualifier. It +// panics until Generation.Freeze chooses every declaration name. +func (d *UnionDeclaration) KindName() string { + return d.kindDeclaration.Name() +} + +// PackagePath returns the import path of the package that declares the union. +func (d *UnionDeclaration) PackagePath() string { + return d.declaration.packagePath() +} + +// Declaration returns the NameDeclaration used for the union type. +func (d *UnionDeclaration) Declaration() *NameDeclaration { + return d.declaration +} + +// KindDeclaration returns the NameDeclaration used for the union kind type. +func (d *UnionDeclaration) KindDeclaration() *NameDeclaration { + return d.kindDeclaration +} + +// KindConst returns the branch kind constant without a package qualifier. +func (d *UnionBranchDeclaration) KindConst() string { + return d.kindDeclaration.Name() +} + +// Constructor returns the branch constructor name without a package qualifier. +func (d *UnionBranchDeclaration) Constructor() string { + return d.constructorDeclaration.Name() +} + +// KindDeclaration returns the NameDeclaration used for the branch kind +// constant. +func (d *UnionBranchDeclaration) KindDeclaration() *NameDeclaration { + return d.kindDeclaration +} + +// ConstructorDeclaration returns the NameDeclaration used for the branch +// constructor. +func (d *UnionBranchDeclaration) ConstructorDeclaration() *NameDeclaration { + return d.constructorDeclaration +} + +// Type returns the generated branch type and true when the branch has one. +func (d *UnionBranchDeclaration) Type() (*TypeDeclaration, bool) { + return d.branchType, d.branchType != nil +} + +// Ref returns the Go type reference for dataType, including the pointer chosen +// by Goa for named objects, unions, and aliases. +func (d *TypeDeclaration) Ref(dataType expr.DataType) string { + return goTypeRef(d.Name(), dataType) +} + +// DeclareName records one package-level Go name. Each supplied key will return +// that same name during type formatting. Repeating the same name and keys has +// no effect. It returns an error if the name belongs to another package, cannot +// be ordered, or a key already selects another name. +func (p *GeneratedPackage) DeclareName(declaration *NameDeclaration, keys ...Hasher) error { + if p.frozen { + return fmt.Errorf("generated package %q is frozen", p.path) + } + if err := validateNameDeclaration(declaration); err != nil { + return err + } + if err := p.validateNameBindings(declaration, keys); err != nil { + return err + } + if declaration.owner != nil { + if declaration.owner == p { + return p.recordNameBindings(declaration, keys) + } + return fmt.Errorf( + "package name %q already belongs to generated package %q", + declaration.preferredName(), + declaration.owner.path, + ) + } + if declaration.base != nil { + switch { + case declaration.base.owner == nil: + return fmt.Errorf( + "generated package %q cannot declare preferred %s %q: base declaration is not owned", + p.path, + declaration.kind, + declaration.preferredName(), + ) + case declaration.base.owner != p: + return fmt.Errorf( + "generated package %q cannot declare preferred %s %q: base declaration belongs to generated package %q", + p.path, + declaration.kind, + declaration.preferredName(), + declaration.base.owner.path, + ) + } + } + if declaration.exact { + if existing, ok := p.exactNames[declaration.preferred]; ok { + return fmt.Errorf( + "generated package %q cannot declare exact %s %q: already declared by exact %s", + p.path, + declaration.kind, + declaration.preferred, + existing.kind, + ) + } + p.exactNames[declaration.preferred] = declaration + } else { + if err := validatePackageNameOrder(declaration.order); err != nil { + return fmt.Errorf( + "generated package %q cannot declare preferred %s %q: %w", + p.path, + declaration.kind, + declaration.preferredName(), + err, + ) + } + for _, existing := range p.names { + if existing.exact || reflect.TypeOf(existing.order) != reflect.TypeOf(declaration.order) { + continue + } + if existing.order.ComparePackageName(declaration.order) == 0 { + return fmt.Errorf( + "generated package %q cannot deterministically order preferred %s %q", + p.path, + declaration.kind, + declaration.preferredName(), + ) + } + } + } + declaration.owner = p + p.names = append(p.names, declaration) + return p.recordNameBindings(declaration, keys) +} + +// DeclareDependentName adds a generated declaration named by placing prefix and +// suffix around base's final name. base must already be declared in p. +func (p *GeneratedPackage) DeclareDependentName(kind PackageNameKind, base *NameDeclaration, prefix, suffix string, order PackageNameOrder) (*NameDeclaration, error) { + declaration := newDependentName(kind, base, prefix, suffix, order) + if err := p.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil +} + +// DeclareGeneratedType adds a type produced by a generator plugin. Goa assigns +// a stable final name but does not associate the declaration with an authored +// Goa type. +func (p *GeneratedPackage) DeclareGeneratedType(preferredName string, order PackageNameOrder) (*TypeDeclaration, error) { + declaration := NewPreferredName(NameType, Goify(preferredName, true), ExportedName, order) + if err := p.DeclareName(declaration); err != nil { + return nil, fmt.Errorf("declare generated type %q: %w", preferredName, err) + } + return &TypeDeclaration{declaration: declaration}, nil +} + +// DeclareUserType adds userType with its exact exported Go name and returns the +// generated declaration. Repeated calls for the same source type return the +// same declaration. +func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclaration, error) { + if p.frozen { + return nil, fmt.Errorf("generated package %q is frozen", p.path) + } + origin := userType.Origin() + name := Goify(userType.Name(), true) + if declaration, ok := p.userTypes[origin]; ok { + if declaration.declaration.preferred != name { + return nil, fmt.Errorf( + "user type origin %q cannot declare both %q and %q in generated package %q", + origin.Name(), + declaration.declaration.preferred, + name, + p.path, + ) + } + return declaration, nil + } + + nameDeclaration := NewExactName(NameType, name) + if err := p.DeclareName(nameDeclaration); err != nil { + return nil, fmt.Errorf("declare user type %q: %w", userType.Name(), err) + } + declaration := &TypeDeclaration{declaration: nameDeclaration} + if err := p.bindType(origin, declaration); err != nil { + return nil, err + } + p.bindName(nameDeclaration, userType) + p.userTypes[origin] = declaration + return declaration, nil +} + +// DeclareDerivedType adds one generated form of a source type. Repeated calls +// with the same DerivedTypeID return the same declaration. +func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name string) (*TypeDeclaration, error) { + return p.declareDerivedType(identity, name, "") +} + +// declareDerivedType adds one generated form and uses api only to order method +// wrappers contributed by different APIs to the same Go package. +func (p *GeneratedPackage) declareDerivedType(identity DerivedTypeID, name, api string) (*TypeDeclaration, error) { + if p.frozen { + return nil, fmt.Errorf("generated package %q is frozen", p.path) + } + canonicalName := Goify(name, true) + if declaration, ok := p.derivedTypes[identity]; ok { + if declaration.declaration.preferred != canonicalName { + return nil, fmt.Errorf( + "derived type from %q cannot declare both %q and %q in generated package %q", + identity.origin.Name(), + declaration.declaration.preferred, + canonicalName, + p.path, + ) + } + return declaration, nil + } + order := newDerivedTypeOrder(identity, canonicalName, api) + nameDeclaration := NewPreferredName(NameType, canonicalName, ExportedName, order) + if err := p.DeclareName(nameDeclaration); err != nil { + return nil, err + } + declaration := &TypeDeclaration{declaration: nameDeclaration} + if identity.kind.isMethodType() { + if err := p.bindType(identity.origin, declaration); err != nil { + return nil, err + } + } + p.derivedTypes[identity] = declaration + return declaration, nil +} + +// DeclareMethodType adds the wrapper described by the MethodTypeIdentity value +// for source. It returns the declaration and DerivedTypeID used by later +// lookups. +func (p *GeneratedPackage) DeclareMethodType(identity MethodTypeIdentity, source expr.UserType) (*TypeDeclaration, DerivedTypeID, error) { + if identity.origin == nil || identity.origin != source.Origin() { + return nil, DerivedTypeID{}, fmt.Errorf( + "user type %q is not the compiler-owned method wrapper %q", + source.Name(), + identity.UID(), + ) + } + derived := newDerivedTypeID(source, identity.kind) + declaration, err := p.declareDerivedType(derived, identity.Name(), identity.api) + return declaration, derived, err +} + +// DeclareUnion adds the generated union type, kind type, branch constants, and +// branch constructors. Unions with the same UnionTypeID return the same +// declaration. Reading generated names panics until Generation.Freeze chooses +// every declaration name. +func (p *GeneratedPackage) DeclareUnion(union *expr.Union) (*UnionDeclaration, error) { + if p.frozen { + return nil, fmt.Errorf("generated package %q is frozen", p.path) + } + identity := NewUnionTypeID(union) + if planned, ok := p.unions[identity]; ok { + return planned.declaration, nil + } + + nameDeclaration := NewPreferredName(NameType, union.Name(), ExportedName, unionNameOrder{ + union: identity, + role: unionTypeNameRole, + }) + kindDeclaration := newDependentName(NameType, nameDeclaration, "", "Kind", unionNameOrder{ + union: identity, + role: unionKindNameRole, + }) + if err := p.DeclareName(nameDeclaration); err != nil { + return nil, err + } + if err := p.DeclareName(kindDeclaration); err != nil { + return nil, err + } + declaration := &UnionDeclaration{ + declaration: nameDeclaration, + kindDeclaration: kindDeclaration, + } + p.bindName(nameDeclaration, identity) + branches := make(map[unionBranchID]*UnionBranchDeclaration, len(union.Values)) + for _, branch := range union.Values { + branchIdentity := unionBranchID{name: branch.Name} + if _, ok := branches[branchIdentity]; ok { + return nil, fmt.Errorf("union %q declares branch %q more than once", union.Name(), branch.Name) + } + kindDeclaration := newDependentName( + NameConstant, + declaration.kindDeclaration, + "", + Goify(branch.Name, true), + unionNameOrder{union: identity, role: unionBranchKindNameRole, branch: branch.Name}, + ) + constructorDeclaration := newDependentName( + NameFunction, + declaration.declaration, + "New", + Goify(branch.Name, true), + unionNameOrder{union: identity, role: unionBranchConstructorNameRole, branch: branch.Name}, + ) + if err := p.DeclareName(kindDeclaration); err != nil { + return nil, err + } + if err := p.DeclareName(constructorDeclaration); err != nil { + return nil, err + } + branches[branchIdentity] = &UnionBranchDeclaration{ + kindDeclaration: kindDeclaration, + constructorDeclaration: constructorDeclaration, + } + } + p.unions[identity] = &unionDeclaration{ + union: union, + declaration: declaration, + branches: branches, + } + return declaration, nil +} + +// DeclareUnionBranchType adds the generated type used by branchName in union. +// Equivalent union expressions share that declaration. Types written directly +// in the DSL must use DeclareUserType instead. +func (p *GeneratedPackage) DeclareUnionBranchType(union *expr.Union, branchName string, userType expr.UserType) (*TypeDeclaration, error) { + if p.frozen { + return nil, fmt.Errorf("generated package %q is frozen", p.path) + } + planned, ok := p.unions[NewUnionTypeID(union)] + if !ok { + return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) + } + if !unionHasBranchType(union, branchName, userType) { + return nil, fmt.Errorf("user type %q is not branch %q of union %q", userType.Name(), branchName, union.Name()) + } + + identity := unionBranchID{name: branchName} + branch, ok := planned.branches[identity] + if !ok { + return nil, fmt.Errorf("branch %q of union %q is not declared in generated package %q", branchName, union.Name(), p.path) + } + if branch.branchType != nil { + name := Goify(userType.Name(), true) + if branch.branchType.declaration.preferred != name { + return nil, fmt.Errorf( + "branch %q of union %q cannot declare both %q and %q", + branchName, + union.Name(), + branch.branchType.declaration.preferred, + name, + ) + } + if err := p.bindType(userType.Origin(), branch.branchType); err != nil { + return nil, err + } + return branch.branchType, nil + } + typeName := Goify(userType.Name(), true) + nameDeclaration := NewPreferredName(NameType, typeName, ExportedName, unionNameOrder{ + union: NewUnionTypeID(union), + role: unionBranchTypeNameRole, + branch: branchName, + }) + if err := p.DeclareName(nameDeclaration); err != nil { + return nil, err + } + declaration := &TypeDeclaration{declaration: nameDeclaration} + origin := userType.Origin() + if err := p.bindType(origin, declaration); err != nil { + return nil, err + } + branch.branchType = declaration + return declaration, nil +} + +// UserType returns the declaration previously added for userType. It does not +// add a name. +func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, error) { + if declaration, ok := p.userTypes[userType.Origin()]; ok { + return declaration, nil + } + return nil, fmt.Errorf("user type %q is not declared in generated package %q", userType.Name(), p.path) +} + +// Type returns the exact user type declaration or generated union branch type +// previously associated with userType's source declaration. +func (p *GeneratedPackage) Type(userType expr.UserType) (*TypeDeclaration, error) { + if declaration, ok := p.typeBindings[userType.Origin()]; ok { + return declaration, nil + } + return nil, fmt.Errorf("user type %q has no declaration in generated package %q", userType.Name(), p.path) +} + +// DerivedType returns the generated view type previously added for the supplied +// DerivedTypeID. +func (p *GeneratedPackage) DerivedType(identity DerivedTypeID) (*TypeDeclaration, error) { + if declaration, ok := p.derivedTypes[identity]; ok { + return declaration, nil + } + return nil, fmt.Errorf( + "derived type from %q is not declared in generated package %q", + identity.origin.Name(), + p.path, + ) +} + +// UnionBranch returns the constant, constructor, and optional type previously +// added for branchName. It does not add names. +func (p *GeneratedPackage) UnionBranch(union *expr.Union, branchName string) (*UnionBranchDeclaration, error) { + planned, ok := p.unions[NewUnionTypeID(union)] + if !ok { + return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) + } + branch, ok := planned.branches[unionBranchID{name: branchName}] + if !ok { + return nil, fmt.Errorf("branch %q of union %q is not declared in generated package %q", branchName, union.Name(), p.path) + } + return branch, nil +} + +// Union returns the declaration previously added for union. It does not add a +// name. +func (p *GeneratedPackage) Union(union *expr.Union) (*UnionDeclaration, error) { + if planned, ok := p.unions[NewUnionTypeID(union)]; ok { + return planned.declaration, nil + } + return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) +} + +// UnionBranchType returns the generated type previously added for branchName. +// It returns an error when the branch does not generate a type. +func (p *GeneratedPackage) UnionBranchType(union *expr.Union, branchName string) (*TypeDeclaration, error) { + branch, err := p.UnionBranch(union, branchName) + if err != nil { + return nil, err + } + if branch.branchType == nil { + return nil, fmt.Errorf("branch %q of union %q has no generated type in package %q", branchName, union.Name(), p.path) + } + return branch.branchType, nil +} + +// Scope returns the package's NameScope after all names are final. It panics +// until Generation.Freeze chooses every declaration name. +func (p *GeneratedPackage) Scope() *NameScope { + if !p.frozen { + panic(fmt.Sprintf("generated package %q scope requested before freeze", p.path)) + } + return p.scope +} + +// ComparePackageName sorts two generated service type names. +func (o derivedTypeOrder) ComparePackageName(other PackageNameOrder) int { + return compareDerivedTypeOrder(o, other.(derivedTypeOrder)) +} + +// ComparePackageName sorts two declarations generated for unions. +func (o unionNameOrder) ComparePackageName(other PackageNameOrder) int { + right := other.(unionNameOrder) + if compared := strings.Compare(string(o.union), string(right.union)); compared != 0 { + return compared + } + if o.role != right.role { + return int(o.role) - int(right.role) + } + return strings.Compare(o.branch, right.branch) +} + +// newGeneratedPackage returns an empty package record for path and outputDir. +func newGeneratedPackage(claim, path, outputDir string) *GeneratedPackage { + return &GeneratedPackage{ + claim: claim, + path: path, + outputDir: outputDir, + scope: NewNameScope(), + exactNames: make(map[string]*NameDeclaration), + nameBindings: make(map[string]*NameDeclaration), + importPlan: &importAliasPlan{ + candidates: make(map[string]*importAliasCandidate), + }, + userTypes: make(map[expr.UserType]*TypeDeclaration), + typeBindings: make(map[expr.UserType]*TypeDeclaration), + derivedTypes: make(map[DerivedTypeID]*TypeDeclaration), + unions: make(map[UnionTypeID]*unionDeclaration), + } +} + +// freeze chooses exact names first, then names that may receive a number, and +// finally names built from another chosen name. It then rejects any attempt to +// add or change a name. +func (p *GeneratedPackage) freeze() error { + if err := p.freezeImports(); err != nil { + return err + } + exact := make([]*NameDeclaration, 0, len(p.names)) + preferred := make([]*NameDeclaration, 0, len(p.names)) + dependent := make([]*NameDeclaration, 0, len(p.names)) + for _, declaration := range p.names { + switch { + case declaration.exact: + exact = append(exact, declaration) + case declaration.base == nil: + preferred = append(preferred, declaration) + default: + dependent = append(dependent, declaration) + } + } + slices.SortFunc(exact, func(left, right *NameDeclaration) int { + return strings.Compare(left.preferred, right.preferred) + }) + for _, declaration := range exact { + declaration.final = p.scope.Unique(declaration.preferred) + if declaration.final != declaration.preferred { + return fmt.Errorf( + "generated package %q cannot preserve exact %s name %q", + p.path, + declaration.kind, + declaration.preferred, + ) + } + declaration.frozen = true + } + slices.SortFunc(preferred, comparePackageNames) + for _, declaration := range preferred { + declaration.final = p.scope.Unique(declaration.preferred) + declaration.frozen = true + } + for len(dependent) > 0 { + ready := dependent[:0] + waiting := make([]*NameDeclaration, 0, len(dependent)) + for _, declaration := range dependent { + if declaration.base.frozen { + ready = append(ready, declaration) + } else { + waiting = append(waiting, declaration) + } + } + if len(ready) == 0 { + return fmt.Errorf("generated package %q contains a package-name dependency cycle", p.path) + } + slices.SortFunc(ready, comparePackageNames) + for _, declaration := range ready { + declaration.final = p.scope.Unique(declaration.preferredName()) + declaration.frozen = true + } + dependent = waiting + } + for _, declaration := range p.names { + for _, hash := range declaration.hashes { + p.scope.bind(hash, declaration.final) + } + } + p.scope.Freeze() + p.frozen = true + return nil +} + +// bindName makes lookups for hash return declaration's chosen Go name. +func (p *GeneratedPackage) bindName(declaration *NameDeclaration, hash Hasher) { + if err := p.recordNameBindings(declaration, []Hasher{hash}); err != nil { + panic(err) + } +} + +// validateNameBindings rejects nil lookup keys and keys that already return a +// different Go declaration name. +func (p *GeneratedPackage) validateNameBindings(declaration *NameDeclaration, keys []Hasher) error { + for _, key := range keys { + if key == nil { + return fmt.Errorf("generated package %q cannot declare a nil lookup key", p.path) + } + hash := key.Hash() + if existing := p.nameBindings[hash]; existing != nil && existing != declaration { + return fmt.Errorf( + "generated package %q lookup key %q already belongs to %s %q", + p.path, + hash, + existing.kind, + existing.preferredName(), + ) + } + } + return nil +} + +// recordNameBindings makes each lookup key return declaration's chosen Go +// name. +func (p *GeneratedPackage) recordNameBindings(declaration *NameDeclaration, keys []Hasher) error { + if err := p.validateNameBindings(declaration, keys); err != nil { + return err + } + for _, key := range keys { + hash := key.Hash() + if p.nameBindings[hash] == declaration { + continue + } + p.nameBindings[hash] = declaration + declaration.hashes = append(declaration.hashes, key) + } + return nil +} + +// bindType associates one source type with one generated declaration. Repeating +// the same association has no effect; using a different declaration returns an +// error. +func (p *GeneratedPackage) bindType(origin expr.UserType, declaration *TypeDeclaration) error { + if existing, ok := p.typeBindings[origin]; ok { + if existing == declaration { + return nil + } + return fmt.Errorf( + "user type %q is already bound to another declaration in generated package %q", + origin.Name(), + p.path, + ) + } + p.typeBindings[origin] = declaration + return nil +} + +// newDerivedTypeID identifies one generated view, payload, or result type by +// the source type it came from. It panics when that source is unknown. +func newDerivedTypeID(source expr.UserType, kind derivedTypeKind) DerivedTypeID { + if source == nil || source.Origin() == nil { + panic("derived type source has no declaration origin") + } + return DerivedTypeID{origin: source.Origin(), kind: kind} +} + +// newMethodTypeIdentity records the API, generated wrapper name, whether it +// holds a payload or result, and the key used to repeat examples for one +// service method value. +func newMethodTypeIdentity(apiName, methodName string, kind derivedTypeKind, exampleIdentity expr.ExampleIdentity) MethodTypeIdentity { + if !kind.isMethodType() { + panic("method type identity requires a method role") + } + return MethodTypeIdentity{ + api: apiName, + name: Goify(methodName, true) + kind.methodSuffix(), + kind: kind, + exampleIdentity: exampleIdentity, + } +} + +// bind records the source type wrapped for one method. Goa uses the pointer only +// while preparing this run; it does not change generated names or identifiers. +func (i MethodTypeIdentity) bind(source expr.UserType) MethodTypeIdentity { + i.origin = source.Origin() + return i +} + +// methodSuffix returns the Go name suffix for a method payload or result +// wrapper. +func (k derivedTypeKind) methodSuffix() string { + switch k { + case methodPayloadTypeKind: + return "Payload" + case methodStreamingPayloadTypeKind: + return "StreamingPayload" + case methodResultTypeKind: + return "Result" + case methodStreamingResultTypeKind: + return "StreamingResult" + default: + panic("derived type kind is not a method role") + } +} + +// isMethodType reports whether this kind is a service method payload or result +// wrapper. +func (k derivedTypeKind) isMethodType() bool { + return k >= methodPayloadTypeKind && k <= methodStreamingResultTypeKind +} + +// newDerivedTypeOrder copies the values used to sort generated type names so +// expression pointer addresses cannot affect their suffixes. +func newDerivedTypeOrder(identity DerivedTypeID, name, api string) derivedTypeOrder { + return derivedTypeOrder{ + kind: identity.kind, + name: name, + sourceName: identity.origin.Name(), + sourceID: identity.origin.ID(), + api: api, + } +} + +// compareDerivedTypeOrder sorts generated view types by kind, requested name, +// source name, source ID, and API name. +func compareDerivedTypeOrder(left, right derivedTypeOrder) int { + if left.kind != right.kind { + return int(left.kind) - int(right.kind) + } + for _, values := range [][2]string{ + {left.name, right.name}, + {left.sourceName, right.sourceName}, + {left.sourceID, right.sourceID}, + {left.api, right.api}, + } { + if compared := strings.Compare(values[0], values[1]); compared != 0 { + return compared + } + } + return 0 +} + +// unionHasBranchType reports whether branchName in this union expression uses +// userType. UnionTypeID handles equivalent copies of the whole union. +func unionHasBranchType(union *expr.Union, branchName string, userType expr.UserType) bool { + for _, branch := range union.Values { + if branch.Name == branchName && branch.Attribute.Type == userType { + return true + } + } + return false +} diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go new file mode 100644 index 0000000000..f8a6b508dd --- /dev/null +++ b/codegen/generated_types_test.go @@ -0,0 +1,1434 @@ +// This file verifies that one generated package owns the public names of every +// relocated declaration planned into it. +package codegen + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // testNameOrder supplies stable typed ordering facts to package-name tests. + testNameOrder struct { + value string + } + + // alphaTestNameOrder and omegaTestNameOrder are unrelated order families + // whose comparers reject foreign values. + alphaTestNameOrder string + omegaTestNameOrder string + + // unstableTestNameOrder is invalid because its value may change after + // declaration collection. + unstableTestNameOrder []string + + // indirectTestNameOrder is invalid because it contains pointer identity. + indirectTestNameOrder struct { + value *string + } + + // testNameKey supplies the lookup key used by generated type formatters. + testNameKey string +) + +// ComparePackageName orders declarations from the same test family. +func (o testNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(o.value, other.(testNameOrder).value) +} + +// ComparePackageName orders declarations from the alpha test family. +func (o alphaTestNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(string(o), string(other.(alphaTestNameOrder))) +} + +// ComparePackageName orders declarations from the omega test family. +func (o omegaTestNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(string(o), string(other.(omegaTestNameOrder))) +} + +// ComparePackageName orders an invalid mutable test value. +func (o unstableTestNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(strings.Join(o, "/"), strings.Join(other.(unstableTestNameOrder), "/")) +} + +// ComparePackageName orders an invalid pointer-backed test value. +func (o indirectTestNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(*o.value, *other.(indirectTestNameOrder).value) +} + +// Hash returns the lookup key used by a generated package. +func (k testNameKey) Hash() string { + return string(k) +} + +// TestDeclareNameBindsLookupKeys checks that a plugin can declare a name and +// use the same final name through its normal typed lookup. +func TestDeclareNameBindsLookupKeys(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/specs") + key := testNameKey("request") + declaration := NewPreferredName(NameType, "Request", ExportedName, testNameOrder{value: "request"}) + + require.NoError(t, pkg.DeclareName(declaration, key)) + require.NoError(t, pkg.DeclareName(declaration, key)) + require.NoError(t, generation.Freeze()) + require.Equal(t, declaration.Name(), pkg.Scope().HashedUnique(key, "Ignored")) +} + +// TestDeclareNameRejectsAnotherDeclarationForOneKey checks that one lookup +// key cannot select two package-level names. +func TestDeclareNameRejectsAnotherDeclarationForOneKey(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/specs") + key := testNameKey("request") + first := NewPreferredName(NameType, "Request", ExportedName, testNameOrder{value: "first"}) + second := NewPreferredName(NameType, "Request", ExportedName, testNameOrder{value: "second"}) + + require.NoError(t, pkg.DeclareName(first, key)) + err := pkg.DeclareName(second, key) + require.ErrorContains(t, err, "lookup key") +} + +// TestNameDeclarationOwnsOnePackageNamespace verifies that exact and preferred +// package symbols of every kind share one collision domain and one frozen name. +func TestNameDeclarationOwnsOnePackageNamespace(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + exact := NewExactName(NameType, "Build") + preferred := NewPreferredName(NameFunction, "Build", ExportedName, testNameOrder{value: "build"}) + + require.NoError(t, types.DeclareName(exact)) + require.NoError(t, types.DeclareName(exact)) + require.NoError(t, types.DeclareName(preferred)) + require.Equal(t, NameType, exact.Kind()) + require.Panics(t, func() { exact.Name() }) + require.Panics(t, func() { preferred.Name() }) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "Build", exact.Name()) + require.Equal(t, "Build2", preferred.Name()) + require.Equal(t, "Build", exact.Name()) + + for _, kind := range []PackageNameKind{NameType, NameFunction, NameConstant, NameVariable} { + collisionGeneration := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, collisionGeneration, "generated.local/gen/types") + require.NoError(t, pkg.DeclareName(NewExactName(NameType, "Shared"))) + err := pkg.DeclareName(NewExactName(kind, "Shared")) + require.ErrorContains(t, err, "Shared") + } +} + +// TestDeclareGeneratedTypeUsesStablePackageNames verifies that plugins can +// declare generated types without claiming that they are authored Goa types. +func TestDeclareGeneratedTypeUsesStablePackageNames(t *testing.T) { + declare := func(reverse bool) (string, string) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + firstOrder := testNameOrder{value: "first"} + secondOrder := testNameOrder{value: "second"} + var first, second *TypeDeclaration + var err error + if reverse { + second, err = pkg.DeclareGeneratedType("Value", secondOrder) + require.NoError(t, err) + first, err = pkg.DeclareGeneratedType("Value", firstOrder) + } else { + first, err = pkg.DeclareGeneratedType("Value", firstOrder) + require.NoError(t, err) + second, err = pkg.DeclareGeneratedType("Value", secondOrder) + } + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + _, err = pkg.DeclareGeneratedType("Other", testNameOrder{value: "other"}) + require.ErrorContains(t, err, "frozen") + return first.Name(), second.Name() + } + + first, second := declare(false) + reversedFirst, reversedSecond := declare(true) + require.Equal(t, "Value", first) + require.Equal(t, "Value2", second) + require.Equal(t, first, reversedFirst) + require.Equal(t, second, reversedSecond) +} + +// TestGeneratedPackagePreservesExactGoNames checks that names produced by +// another Go generator are stored without changing their spelling. +func TestGeneratedPackagePreservesExactGoNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + private := NewExactName(NameType, "api2HttpClient") + handler := NewExactName(NameFunction, "_API2_HTTPHandler") + require.NoError(t, types.DeclareName(private)) + require.NoError(t, types.DeclareName(handler)) + require.NoError(t, generation.Freeze()) + require.Equal(t, "api2HttpClient", private.Name()) + require.Equal(t, "_API2_HTTPHandler", handler.Name()) +} + +// TestGeneratedPackageRejectsInvalidExactGoName checks that an exact name +// must already be a valid Go identifier. +func TestGeneratedPackageRejectsInvalidExactGoName(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + err := types.DeclareName(NewExactName(NameType, "not a name")) + require.EqualError(t, err, `package name "not a name" is not a valid Go identifier`) +} + +// TestDependentNameUsesFrozenBase verifies that companion declarations derive +// their spelling from the exact final name selected for their base declaration. +func TestDependentNameUsesFrozenBase(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + require.NoError(t, pkg.DeclareName(NewExactName(NameType, "Result"))) + base := NewPreferredName(NameType, "Result", ExportedName, testNameOrder{value: "base"}) + require.NoError(t, pkg.DeclareName(base)) + validator, err := pkg.DeclareDependentName( + NameFunction, + base, + "Validate", + "", + testNameOrder{value: "validator"}, + ) + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "Result2", base.Name()) + require.Equal(t, "ValidateResult2", validator.Name()) +} + +// TestNameDeclarationRejectsUnownedPackageAccess verifies that only a package +// catalog can make internal declaration ownership available to typed records. +func TestNameDeclarationRejectsUnownedPackageAccess(t *testing.T) { + declaration := NewExactName(NameType, "Value") + require.Panics(t, func() { declaration.packagePath() }) +} + +// TestNameDeclarationRejectsEmptyPreferredName verifies that exact and +// suffixable declarations cannot mutate a package catalog without a Go name. +func TestNameDeclarationRejectsEmptyPreferredName(t *testing.T) { + tests := []struct { + name string + declaration *NameDeclaration + }{ + {"exact", NewExactName(NameType, "")}, + {"preferred", NewPreferredName(NameFunction, "", ExportedName, testNameOrder{value: "empty"})}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + + err := pkg.DeclareName(test.declaration) + require.ErrorContains(t, err, "name must not be empty") + require.Nil(t, test.declaration.owner) + require.Empty(t, pkg.names) + require.Empty(t, pkg.exactNames) + }) + } +} + +// TestNameDeclarationRejectsInvalidKind verifies that direct and dependent +// declarations cannot mutate a package catalog with an unknown category. +func TestNameDeclarationRejectsInvalidKind(t *testing.T) { + tests := []struct { + name string + declaration func(*testing.T, *GeneratedPackage) *NameDeclaration + wantNames int + }{ + { + "exact", + func(*testing.T, *GeneratedPackage) *NameDeclaration { + return NewExactName(0, "Value") + }, + 0, + }, + { + "preferred", + func(*testing.T, *GeneratedPackage) *NameDeclaration { + return NewPreferredName(NameVariable+1, "Value", ExportedName, testNameOrder{value: "invalid"}) + }, + 0, + }, + { + "dependent", + func(t *testing.T, pkg *GeneratedPackage) *NameDeclaration { + base := NewPreferredName(NameType, "Value", ExportedName, testNameOrder{value: "base"}) + require.NoError(t, pkg.DeclareName(base)) + return newDependentName(0, base, "New", "", testNameOrder{value: "dependent"}) + }, + 1, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + declaration := test.declaration(t, pkg) + + err := pkg.DeclareName(declaration) + require.ErrorContains(t, err, "invalid package name kind") + require.Nil(t, declaration.owner) + require.Len(t, pkg.names, test.wantNames) + }) + } +} + +// TestNameDeclarationPreferredOrder verifies that typed stable identity, not +// discovery order, decides suffix ownership and rejects an indistinguishable tie. +func TestNameDeclarationPreferredOrder(t *testing.T) { + declare := func(reverse bool) (string, string) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + first := NewPreferredName(NameFunction, "Build", ExportedName, testNameOrder{value: "a"}) + second := NewPreferredName(NameConstant, "Build", ExportedName, testNameOrder{value: "b"}) + declarations := []*NameDeclaration{first, second} + if reverse { + declarations[0], declarations[1] = declarations[1], declarations[0] + } + for _, declaration := range declarations { + require.NoError(t, pkg.DeclareName(declaration)) + } + require.NoError(t, generation.Freeze()) + return first.Name(), second.Name() + } + + first, second := declare(false) + reversedFirst, reversedSecond := declare(true) + require.Equal(t, "Build", first) + require.Equal(t, "Build2", second) + require.Equal(t, first, reversedFirst) + require.Equal(t, second, reversedSecond) + + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + require.NoError(t, pkg.DeclareName(NewPreferredName( + NameFunction, + "Build", + ExportedName, + testNameOrder{value: "same"}, + ))) + err := pkg.DeclareName(NewPreferredName( + NameVariable, + "Build", + ExportedName, + testNameOrder{value: "same"}, + )) + require.ErrorContains(t, err, "cannot deterministically order") +} + +// TestPreferredNameVisibility verifies preferred declarations preserve their +// requested package visibility while sharing deterministic collision handling. +func TestPreferredNameVisibility(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + exported := NewPreferredName(NameFunction, "build value", ExportedName, testNameOrder{value: "exported"}) + privateFirst := NewPreferredName(NameFunction, "Build Value", UnexportedName, testNameOrder{value: "private-a"}) + privateSecond := NewPreferredName(NameFunction, "build value", UnexportedName, testNameOrder{value: "private-b"}) + require.NoError(t, pkg.DeclareName(exported)) + require.NoError(t, pkg.DeclareName(privateSecond)) + require.NoError(t, pkg.DeclareName(privateFirst)) + require.NoError(t, generation.Freeze()) + require.Equal(t, "BuildValue", exported.Name()) + require.Equal(t, "buildValue", privateFirst.Name()) + require.Equal(t, "buildValue2", privateSecond.Name()) +} + +// TestNameDeclarationOrdersConcreteFamilies verifies that unrelated named +// order types never receive each other's values and remain discovery-order independent. +func TestNameDeclarationOrdersConcreteFamilies(t *testing.T) { + declare := func(reverse bool) (string, string) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + alpha := NewPreferredName(NameFunction, "Build", ExportedName, alphaTestNameOrder("same")) + omega := NewPreferredName(NameConstant, "Build", ExportedName, omegaTestNameOrder("same")) + declarations := []*NameDeclaration{alpha, omega} + if reverse { + declarations[0], declarations[1] = declarations[1], declarations[0] + } + for _, declaration := range declarations { + require.NoError(t, pkg.DeclareName(declaration)) + } + require.NoError(t, generation.Freeze()) + return alpha.Name(), omega.Name() + } + + alpha, omega := declare(false) + reversedAlpha, reversedOmega := declare(true) + require.Equal(t, "Build", alpha) + require.Equal(t, "Build2", omega) + require.Equal(t, alpha, reversedAlpha) + require.Equal(t, omega, reversedOmega) +} + +// TestNameDeclarationRejectsUnstableOrderTypes verifies that collection +// returns deterministic errors instead of accepting mutable or ambiguous order values. +func TestNameDeclarationRejectsUnstableOrderTypes(t *testing.T) { + stable := testNameOrder{value: "stable"} + tests := []struct { + name string + order PackageNameOrder + }{ + {"nil", nil}, + {"pointer", &stable}, + {"unnamed", struct{ PackageNameOrder }{PackageNameOrder: stable}}, + {"slice", unstableTestNameOrder{"mutable"}}, + {"pointer field", indirectTestNameOrder{value: new(string)}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + declaration := NewPreferredName(NameFunction, "Build", ExportedName, test.order) + err := pkg.DeclareName(declaration) + require.ErrorContains(t, err, "stable concrete named value type") + }) + } +} + +// TestNameDeclarationRejectsDependentOrderTie verifies that dependency phase +// ordering cannot hide indistinguishable facts within one concrete family. +func TestNameDeclarationRejectsDependentOrderTie(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + base := NewPreferredName(NameType, "Value", ExportedName, testNameOrder{value: "base"}) + first := newDependentName(NameFunction, base, "New", "First", testNameOrder{value: "same"}) + second := newDependentName(NameFunction, base, "New", "Second", testNameOrder{value: "same"}) + require.NoError(t, pkg.DeclareName(base)) + require.NoError(t, pkg.DeclareName(first)) + err := pkg.DeclareName(second) + require.ErrorContains(t, err, "cannot deterministically order") +} + +// TestNameDeclarationRejectsInvalidDependentOwners verifies that a dependent +// declaration derives its spelling only from a base already owned by its package. +func TestNameDeclarationRejectsInvalidDependentOwners(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + first := mustClaimTestPackage(t, generation, "generated.local/gen/first") + second := mustClaimTestPackage(t, generation, "generated.local/gen/second") + unowned := NewPreferredName(NameType, "Value", ExportedName, testNameOrder{value: "unowned"}) + dependent := newDependentName(NameFunction, unowned, "New", "", testNameOrder{value: "dependent"}) + err := first.DeclareName(dependent) + require.ErrorContains(t, err, "base declaration is not owned") + + require.NoError(t, first.DeclareName(unowned)) + crossPackage := newDependentName(NameFunction, unowned, "New", "", testNameOrder{value: "cross-package"}) + err = second.DeclareName(crossPackage) + require.ErrorContains(t, err, "base declaration belongs to generated package") +} + +// TestNameDeclarationRejectsMultipleOwners verifies that one canonical name +// record cannot be rebound to another generated package. +func TestNameDeclarationRejectsMultipleOwners(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + declaration := NewExactName(NameType, "Value") + require.NoError(t, mustClaimTestPackage(t, generation, "generated.local/gen/first").DeclareName(declaration)) + err := mustClaimTestPackage(t, generation, "generated.local/gen/second").DeclareName(declaration) + require.ErrorContains(t, err, "already belongs") +} + +// TestNameDeclarationRejectsSameImportAcrossGenerations verifies that +// canonical import spelling does not substitute for exact package ownership. +func TestNameDeclarationRejectsSameImportAcrossGenerations(t *testing.T) { + declaration := NewExactName(NameType, "Value") + first := mustClaimTestPackage(t, mustTestGeneration(t, "generated.local/gen", nil), "generated.local/gen/types") + second := mustClaimTestPackage(t, mustTestGeneration(t, "generated.local/gen", nil), "generated.local/gen/types") + require.NoError(t, first.DeclareName(declaration)) + err := second.DeclareName(declaration) + require.ErrorContains(t, err, "already belongs") +} + +// TestGenerationOwnsName checks declaration ownership before and after names +// are frozen without treating a matching package path as ownership. +func TestGenerationOwnsName(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + declaration := NewExactName(NameType, "Value") + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + require.NoError(t, pkg.DeclareName(declaration)) + + foreignGeneration := mustTestGeneration(t, "generated.local/gen", nil) + foreign := NewExactName(NameType, "Foreign") + require.NoError(t, mustClaimTestPackage(t, foreignGeneration, "generated.local/gen/types").DeclareName(foreign)) + + require.True(t, generation.OwnsName(declaration)) + require.False(t, generation.OwnsName(foreign)) + require.False(t, generation.OwnsName(NewExactName(NameType, "Unregistered"))) + require.False(t, generation.OwnsName(nil)) + require.True(t, pkg.OwnsName(declaration)) + require.False(t, pkg.OwnsName(foreign)) + require.False(t, pkg.OwnsName(nil)) + filePackage, ok := generation.PackageForFile("gen/types/value.go") + require.True(t, ok) + require.Same(t, pkg, filePackage) + _, ok = generation.PackageForFile("gen/other/value.go") + require.False(t, ok) + + require.NoError(t, generation.Freeze()) + require.True(t, generation.OwnsName(declaration)) +} + +// TestGeneratedOutputPathRejectsNormalizedCollisions verifies that equivalent +// import spellings cannot make two requested package identities share output. +func TestGeneratedOutputPathRejectsNormalizedCollisions(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/root/../gen", nil) + first := mustClaimTestPackage(t, generation, "generated.local/gen/types") + require.Equal(t, "generated.local/gen", generation.GenPkg()) + require.Equal(t, "generated.local/gen/types", first.ImportPath()) + require.Equal(t, "gen/types", first.OutputDirectory()) + _, err := generation.ClaimPackage("generated.local/gen/values/../types") + require.EqualError(t, err, + `generated package paths "generated.local/gen/types" and "generated.local/gen/values/../types" normalize to import path "generated.local/gen/types"`) + require.Same(t, first, mustClaimTestPackage(t, generation, "generated.local/gen/types")) + require.NoError(t, generation.Freeze()) +} + +// TestGeneratedOutputPathEmitsCanonicalImport verifies that a package keeps +// its exact planner claim for reuse while generated source sees a clean path. +func TestGeneratedOutputPathEmitsCanonicalImport(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/values/../types") + require.Same(t, pkg, mustClaimTestPackage(t, generation, "generated.local/gen/values/../types")) + require.Equal(t, "generated.local/gen/types", pkg.ImportPath()) + require.Equal(t, "gen/types", pkg.OutputDirectory()) + require.NoError(t, generation.Freeze()) +} + +// TestGeneratedOutputPathRejectsLateNormalizedIdentity verifies that freeze +// does not let a new raw package identity reach an existing canonical package. +func TestGeneratedOutputPathRejectsLateNormalizedIdentity(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + first := mustClaimTestPackage(t, generation, "generated.local/gen/types") + require.NoError(t, generation.Freeze()) + require.Same(t, first, generation.Package("generated.local/gen/types")) + _, err := generation.ClaimPackage("generated.local/gen/types") + require.ErrorContains(t, err, "cannot be claimed after generation freeze") + _, err = generation.ClaimPackage("generated.local/gen/values/../types") + require.ErrorContains(t, err, "cannot be claimed after generation freeze") +} + +// TestGeneratedOutputPathRejectsPortableDirectoryCollision verifies that two +// import identities cannot claim the same directory on a case-insensitive host. +func TestGeneratedOutputPathRejectsPortableDirectoryCollision(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + first := mustClaimTestPackage(t, generation, "generated.local/gen/Foo") + _, err := generation.ClaimPackage("generated.local/gen/foo") + require.EqualError(t, err, + `generated package paths "generated.local/gen/Foo" and "generated.local/gen/foo" resolve to output directory "gen/foo" on a case-insensitive filesystem`) + require.Same(t, first, mustClaimTestPackage(t, generation, "generated.local/gen/Foo")) +} + +// TestGeneratedOutputPathRejectsBackslashes verifies that invalid Go import +// separators are rejected instead of translated into another package identity. +func TestGeneratedOutputPathRejectsBackslashes(t *testing.T) { + _, err := NewGeneration(`generated.local\gen`, nil) + require.ErrorContains(t, err, "backslash") + + generation := mustTestGeneration(t, "generated.local/gen", nil) + _, err = generation.ClaimPackage(`generated.local\gen\types`) + require.ErrorContains(t, err, "contains a backslash") +} + +// TestExplicitOutputPackageClaims verifies that packages outside GenPkg use +// the same canonical import and portable output ownership as ordinary claims. +func TestExplicitOutputPackageClaims(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + starter, err := generation.ClaimOutputPackage("generated.local", ".") + require.NoError(t, err) + reused, err := generation.ClaimOutputPackage("generated.local", "work/..") + require.NoError(t, err) + require.Same(t, starter, reused) + require.Equal(t, "generated.local", starter.ImportPath()) + require.Equal(t, ".", starter.OutputDirectory()) + require.Same(t, starter, generation.Package("generated.local")) + + _, err = generation.ClaimOutputPackage("generated.local", "starter") + require.ErrorContains(t, err, "already mapped") + _, err = generation.ClaimOutputPackage("generated.local/../generated.local", ".") + require.ErrorContains(t, err, "normalize to import path") + require.NoError(t, generation.Freeze()) + _, err = generation.ClaimOutputPackage("generated.local/late", "late") + require.ErrorContains(t, err, "after generation freeze") +} + +// TestExplicitOutputPackageRejectsInvalidDirectories verifies that explicit +// output packages cannot escape the generation working directory or rely on +// host-specific path separators. +func TestExplicitOutputPackageRejectsInvalidDirectories(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + for _, directory := range []string{"../starter", "/starter", "C:/starter", `starter\service`} { + _, err := generation.ClaimOutputPackage("generated.local/starter", directory) + require.Error(t, err, directory) + } +} + +// TestExplicitOutputPackageSharesOrdinaryOwnership verifies that ordinary and +// explicit claims cannot assign one import path or portable directory twice. +func TestExplicitOutputPackageSharesOrdinaryOwnership(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + ordinary := mustClaimTestPackage(t, generation, "generated.local/gen/service") + reused, err := generation.ClaimOutputPackage("generated.local/gen/service", ordinary.OutputDirectory()) + require.NoError(t, err) + require.Same(t, ordinary, reused) + + _, err = generation.ClaimOutputPackage("generated.local/other", "gen/SERVICE") + require.ErrorContains(t, err, "case-insensitive filesystem") +} + +// TestGenerationRejectsImplicitLocalRoots verifies that only the exact local +// output sentinels are accepted as non-module generation roots. +func TestGenerationRejectsImplicitLocalRoots(t *testing.T) { + for _, genpkg := range []string{"", "./", "//"} { + _, err := NewGeneration(genpkg, nil) + require.Error(t, err) + } + for _, genpkg := range []string{".", "/"} { + _, err := NewGeneration(genpkg, nil) + require.NoError(t, err) + } +} + +// TestGeneratedTypeFamiliesContainCanonicalNames verifies that existing type +// and union records expose the package-owned records used for rendering. +func TestGeneratedTypeFamiliesContainCanonicalNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + user, err := pkg.DeclareUserType(generatedUserType("Widget", "widget")) + require.NoError(t, err) + union, alias := generatedUnionWithBranch("text") + unionDeclaration, err := pkg.DeclareUnion(union) + require.NoError(t, err) + branchType, err := pkg.DeclareUnionBranchType(union, "text", alias) + require.NoError(t, err) + branch, err := pkg.UnionBranch(union, "text") + require.NoError(t, err) + + require.Same(t, user.Declaration(), user.Declaration()) + require.Same(t, unionDeclaration.Declaration(), unionDeclaration.Declaration()) + require.Same(t, unionDeclaration.KindDeclaration(), unionDeclaration.KindDeclaration()) + require.Same(t, branchType.Declaration(), branchType.Declaration()) + require.Same(t, branch.KindDeclaration(), branch.KindDeclaration()) + require.Same(t, branch.ConstructorDeclaration(), branch.ConstructorDeclaration()) + require.Panics(t, func() { user.Declaration().Name() }) + require.Panics(t, func() { unionDeclaration.Declaration().Name() }) + + require.NoError(t, generation.Freeze()) + require.Equal(t, user.Name(), user.Declaration().Name()) + require.Equal(t, unionDeclaration.Name(), unionDeclaration.Declaration().Name()) + require.Equal(t, unionDeclaration.KindName(), unionDeclaration.KindDeclaration().Name()) + require.Equal(t, branch.KindConst(), branch.KindDeclaration().Name()) + require.Equal(t, branch.Constructor(), branch.ConstructorDeclaration().Name()) +} + +// TestGeneratedTypesRejectRelocatedNameCollision verifies that one generated +// package rejects distinct DSL names that produce the same exported Go name. +func TestGeneratedTypesRejectRelocatedNameCollision(t *testing.T) { + var first, second expr.UserType + root := RunDSL(t, func() { + first = dsl.Type("foo-bar", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("first", dsl.String) + }) + second = dsl.Type("foo_bar", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("second", dsl.String) + }) + }) + + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + _, err := types.DeclareUserType(first) + require.NoError(t, err) + _, err = types.DeclareUserType(second) + require.ErrorContains(t, err, "foo_bar") + require.ErrorContains(t, err, "FooBar") + require.ErrorContains(t, err, "already declared by exact type") +} + +// TestGenerationOwnsPackageRecords verifies that one generation returns one +// stable package record and scope for each output path. +func TestGenerationOwnsPackageRecords(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + + first := mustClaimTestPackage(t, generation, "generated.local/gen/types") + second := mustClaimTestPackage(t, generation, "generated.local/gen/types") + other := mustClaimTestPackage(t, generation, "generated.local/gen/other") + require.Same(t, first, second) + require.Panics(t, func() { + first.Scope() + }) + require.NoError(t, generation.Freeze()) + require.Same(t, first.Scope(), second.Scope()) + require.NotSame(t, first, other) + require.NotSame(t, first.Scope(), other.Scope()) +} + +// TestGenerationCopiesConstructionState verifies that callers cannot change +// root membership or the generated package path through constructor inputs or +// accessor results before or after freeze. +func TestGenerationCopiesConstructionState(t *testing.T) { + first := RunDSL(t, func() {}) + second := RunDSL(t, func() {}) + roots := []eval.Root{first} + generation := mustTestGeneration(t, "generated.local/gen", roots) + + roots[0] = second + returnedRoots := generation.Roots() + returnedRoots[0] = second + require.Equal(t, "generated.local/gen", generation.GenPkg()) + require.True(t, generation.HasRoot(first)) + require.False(t, generation.HasRoot(second)) + + require.NoError(t, generation.Freeze()) + roots[0] = nil + returnedRoots = generation.Roots() + returnedRoots[0] = second + require.Equal(t, "generated.local/gen", generation.GenPkg()) + require.True(t, generation.HasRoot(first)) + require.False(t, generation.HasRoot(second)) +} + +// TestGeneratedPackageUserTypes verifies that a generated package records one +// declaration per user type and that lookups do not reserve names. +func TestGeneratedPackageUserTypes(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + widget := generatedUserType("Widget", "widget") + missing := generatedUserType("Missing", "missing") + + _, err := types.UserType(missing) + require.ErrorContains(t, err, "not declared") + + first, err := types.DeclareUserType(widget) + require.NoError(t, err) + require.Panics(t, func() { first.Name() }) + require.Equal(t, "generated.local/gen/types", first.PackagePath()) + second, err := types.DeclareUserType(widget) + require.NoError(t, err) + require.Same(t, first, second) + + lookedUp, err := types.UserType(widget) + require.NoError(t, err) + require.Same(t, first, lookedUp) + declaredMissing, err := types.DeclareUserType(missing) + require.NoError(t, err) + require.Panics(t, func() { declaredMissing.Name() }) + require.NoError(t, generation.Freeze()) + require.Equal(t, "Widget", first.Name()) + require.Equal(t, "Missing", declaredMissing.Name()) + require.Equal(t, "Widget", types.Scope().GoTypeName(&expr.AttributeExpr{Type: widget})) +} + +// TestGeneratedPackageExactUserTypesDoNotMerge verifies that structural +// equality does not weaken the exact-name contract for DSL declarations. +func TestGeneratedPackageExactUserTypesDoNotMerge(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + first := generatedUserType("ValueText", "first") + equivalent := generatedUserType("ValueText", "second") + + _, err := types.DeclareUserType(first) + require.NoError(t, err) + _, err = types.DeclareUserType(equivalent) + require.ErrorContains(t, err, "ValueText") + require.ErrorContains(t, err, "already declared") +} + +// TestGeneratedPackageUserTypeCopiesShareDeclaration verifies that exact +// compiler copies use their declaration origin instead of one transient copy +// pointer as package identity. +func TestGeneratedPackageUserTypeCopiesShareDeclaration(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + original := generatedUserType("ValueText", "value-text") + copy := original.Dup(expr.DupAtt(original.Attribute())) + + first, err := types.DeclareUserType(original) + require.NoError(t, err) + second, err := types.DeclareUserType(copy) + require.NoError(t, err) + require.Same(t, first, second) + require.NoError(t, generation.Freeze()) + + lookedUp, err := types.UserType(copy) + require.NoError(t, err) + require.Same(t, first, lookedUp) +} + +// TestGeneratedPackageRepeatedUserTypesCompareCanonicalNames verifies that +// one origin may repeat an equivalent Go spelling but not change declarations. +func TestGeneratedPackageRepeatedUserTypesCompareCanonicalNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + userType := generatedUserType("value-text", "value-text") + expression := userType.(*expr.UserTypeExpr) + first, err := types.DeclareUserType(userType) + require.NoError(t, err) + + expression.TypeName = "value_text" + second, err := types.DeclareUserType(userType) + require.NoError(t, err) + require.Same(t, first, second) + + expression.TypeName = "different" + _, err = types.DeclareUserType(userType) + require.ErrorContains(t, err, "cannot declare both") +} + +// TestGeneratedPackageDerivedTypesUseTypedSourceIdentity verifies that view +// declarations rebuilt in the render phase select the records planned from +// the same exact source declaration. +func TestGeneratedPackageDerivedTypesUseTypedSourceIdentity(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + views := mustClaimTestPackage(t, generation, "generated.local/gen/service/views") + source := generatedUserType("Value", "value") + copy := source.Dup(expr.DupAtt(source.Attribute())) + projectedID := NewProjectedTypeID(source) + viewedID := NewViewedResultTypeID(source) + + projected, err := views.DeclareDerivedType(projectedID, "ValueView") + require.NoError(t, err) + viewed, err := views.DeclareDerivedType(viewedID, "Value") + require.NoError(t, err) + require.NotSame(t, projected, viewed) + require.NoError(t, generation.Freeze()) + + projectedCopy, err := views.DerivedType(NewProjectedTypeID(copy)) + require.NoError(t, err) + require.Same(t, projected, projectedCopy) + viewedCopy, err := views.DerivedType(NewViewedResultTypeID(copy)) + require.NoError(t, err) + require.Same(t, viewed, viewedCopy) + require.Equal(t, "ValueView", projected.Name()) + require.Equal(t, "Value", viewed.Name()) +} + +// TestGeneratedPackageRepeatedDerivedTypesCompareCanonicalNames verifies that +// one typed identity accepts equivalent Go spellings but not another name. +func TestGeneratedPackageRepeatedDerivedTypesCompareCanonicalNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + views := mustClaimTestPackage(t, generation, "generated.local/gen/service/views") + identity := NewProjectedTypeID(generatedUserType("Value", "value")) + first, err := views.DeclareDerivedType(identity, "value-view") + require.NoError(t, err) + + second, err := views.DeclareDerivedType(identity, "value_view") + require.NoError(t, err) + require.Same(t, first, second) + + _, err = views.DeclareDerivedType(identity, "different") + require.ErrorContains(t, err, "cannot declare both") +} + +// TestGeneratedPackageDerivedNamesIgnoreDeclarationOrder verifies that stable +// semantic source identifiers, not traversal order, decide suffix ownership. +func TestGeneratedPackageDerivedNamesIgnoreDeclarationOrder(t *testing.T) { + declare := func(reverse bool) (string, string) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + views := mustClaimTestPackage(t, generation, "generated.local/gen/service/views") + first := generatedUserType("Value", "first") + second := generatedUserType("Value", "second") + ids := []DerivedTypeID{NewProjectedTypeID(first), NewProjectedTypeID(second)} + if reverse { + ids[0], ids[1] = ids[1], ids[0] + } + for _, identity := range ids { + _, err := views.DeclareDerivedType(identity, "ValueView") + require.NoError(t, err) + } + require.NoError(t, generation.Freeze()) + firstDeclaration, err := views.DerivedType(NewProjectedTypeID(first)) + require.NoError(t, err) + secondDeclaration, err := views.DerivedType(NewProjectedTypeID(second)) + require.NoError(t, err) + return firstDeclaration.Name(), secondDeclaration.Name() + } + + first, second := declare(false) + reversedFirst, reversedSecond := declare(true) + require.Equal(t, first, reversedFirst) + require.Equal(t, second, reversedSecond) +} + +// TestGeneratedPackageRejectsAmbiguousDerivedOrder verifies that two distinct +// origins cannot rely on unstable expression shape to break an otherwise +// identical semantic ordering tuple. +func TestGeneratedPackageRejectsAmbiguousDerivedOrder(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + views := mustClaimTestPackage(t, generation, "generated.local/gen/service/views") + first := generatedUserTypeOf("Value", "same", expr.String) + second := generatedUserTypeOf("Value", "same", expr.Int) + + _, err := views.DeclareDerivedType(NewProjectedTypeID(first), "ValueView") + require.NoError(t, err) + _, err = views.DeclareDerivedType(NewProjectedTypeID(second), "ValueView") + require.ErrorContains(t, err, "cannot deterministically order") +} + +// TestGeneratedPackageUnionBranchesShareDeclaration verifies that separately +// allocated copies of one structural union reuse their generated branch alias. +func TestGeneratedPackageUnionBranchesShareDeclaration(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + firstUnion, firstAlias := generatedUnionWithBranch("first") + secondUnion, secondAlias := generatedUnionWithBranch("second") + + _, err := types.DeclareUnion(firstUnion) + require.NoError(t, err) + firstDeclaration, err := types.DeclareUnionBranchType(firstUnion, "text", firstAlias) + require.NoError(t, err) + _, err = types.DeclareUnion(secondUnion) + require.NoError(t, err) + secondDeclaration, err := types.DeclareUnionBranchType(secondUnion, "text", secondAlias) + require.NoError(t, err) + require.Same(t, firstDeclaration, secondDeclaration) + require.Panics(t, func() { firstDeclaration.Name() }) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "ValueText", firstDeclaration.Name()) + lookedUp, err := types.UnionBranchType(secondUnion, "text") + require.NoError(t, err) + require.Same(t, firstDeclaration, lookedUp) +} + +// TestGeneratedPackageUnionBranchesAreIsolatedByUnion verifies that branch +// aliases from different emitted union definitions never collapse together. +func TestGeneratedPackageUnionBranchesAreIsolatedByUnion(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + firstUnion, firstAlias := generatedUnionWithBranch("first") + secondUnion, secondAlias := generatedUnionWithBranch("second") + secondUnion.TypeKey = "kind" + + _, err := types.DeclareUnion(firstUnion) + require.NoError(t, err) + firstDeclaration, err := types.DeclareUnionBranchType(firstUnion, "text", firstAlias) + require.NoError(t, err) + _, err = types.DeclareUnion(secondUnion) + require.NoError(t, err) + secondDeclaration, err := types.DeclareUnionBranchType(secondUnion, "text", secondAlias) + require.NoError(t, err) + require.NotSame(t, firstDeclaration, secondDeclaration) + + require.NoError(t, generation.Freeze()) + require.ElementsMatch(t, []string{"ValueText", "ValueText2"}, []string{ + firstDeclaration.Name(), + secondDeclaration.Name(), + }) +} + +// TestGeneratedPackageUnionFamilyAvoidsExactTypeNames verifies that union +// constants and constructors use package-owned frozen names instead of +// colliding with exact DSL type declarations. +func TestGeneratedPackageUnionFamilyAvoidsExactTypeNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + for _, name := range []string{"ValueKindText", "NewValueText"} { + _, err := types.DeclareUserType(generatedUserType(name, name)) + require.NoError(t, err) + } + union, alias := generatedUnionWithBranch("text") + _, err := types.DeclareUnion(union) + require.NoError(t, err) + aliasDeclaration, err := types.DeclareUnionBranchType(union, "text", alias) + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + branch, err := types.UnionBranch(union, "text") + require.NoError(t, err) + require.Equal(t, "ValueKindText2", branch.KindConst()) + require.Equal(t, "NewValueText2", branch.Constructor()) + branchType, ok := branch.Type() + require.True(t, ok) + require.Same(t, aliasDeclaration, branchType) +} + +// TestGeneratedPackageUnions verifies that emitted-definition identity makes +// equivalent unions idempotent while different unions with the same base name +// receive distinct declarations. +func TestGeneratedPackageUnions(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + first := generatedUnion("type", "value") + equivalent := generatedUnion("type", "value") + different := generatedUnion("kind", "data") + + firstDeclaration, err := types.DeclareUnion(first) + require.NoError(t, err) + require.Panics(t, func() { firstDeclaration.Name() }) + require.Equal(t, "generated.local/gen/types", firstDeclaration.PackagePath()) + equivalentDeclaration, err := types.DeclareUnion(equivalent) + require.NoError(t, err) + require.Same(t, firstDeclaration, equivalentDeclaration) + + differentDeclaration, err := types.DeclareUnion(different) + require.NoError(t, err) + require.Panics(t, func() { differentDeclaration.Name() }) + require.NotSame(t, firstDeclaration, differentDeclaration) + + lookedUp, err := types.Union(equivalent) + require.NoError(t, err) + require.Same(t, firstDeclaration, lookedUp) + require.NoError(t, generation.Freeze()) + require.ElementsMatch(t, []string{"Value", "Value2"}, []string{ + firstDeclaration.Name(), + differentDeclaration.Name(), + }) + require.ElementsMatch(t, []string{"ValueKind", "Value2Kind"}, []string{ + firstDeclaration.KindName(), + differentDeclaration.KindName(), + }) + + reversedGeneration := mustTestGeneration(t, "generated.local/gen", nil) + reversedTypes := mustClaimTestPackage(t, reversedGeneration, "generated.local/gen/types") + reversedDifferent, err := reversedTypes.DeclareUnion(generatedUnion("kind", "data")) + require.NoError(t, err) + reversedFirst, err := reversedTypes.DeclareUnion(generatedUnion("type", "value")) + require.NoError(t, err) + require.NoError(t, reversedGeneration.Freeze()) + require.Equal(t, firstDeclaration.Name(), reversedFirst.Name()) + require.Equal(t, differentDeclaration.Name(), reversedDifferent.Name()) +} + +// TestGeneratedPackageUserTypeWinsUnionNamesRegardlessOfOrder verifies that +// pending unions cannot take exact user-type or discriminator names based on +// traversal order. +func TestGeneratedPackageUserTypeWinsUnionNamesRegardlessOfOrder(t *testing.T) { + for _, unionFirst := range []bool{true, false} { + t.Run(fmt.Sprintf("union first %t", unionFirst), func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + userType := generatedUserType("Value", "value") + kindUserType := generatedUserType("ValueKind", "value-kind") + union := generatedUnion("type", "value") + var ( + userDeclaration *TypeDeclaration + kindDeclaration *TypeDeclaration + unionDeclaration *UnionDeclaration + err error + ) + if unionFirst { + unionDeclaration, err = types.DeclareUnion(union) + require.NoError(t, err) + require.Panics(t, func() { + types.Scope().GoTypeName(&expr.AttributeExpr{Type: union}) + }) + userDeclaration, err = types.DeclareUserType(userType) + require.NoError(t, err) + kindDeclaration, err = types.DeclareUserType(kindUserType) + require.NoError(t, err) + } else { + userDeclaration, err = types.DeclareUserType(userType) + require.NoError(t, err) + kindDeclaration, err = types.DeclareUserType(kindUserType) + require.NoError(t, err) + require.Panics(t, func() { + types.Scope().GoTypeName(&expr.AttributeExpr{Type: userType}) + }) + unionDeclaration, err = types.DeclareUnion(union) + require.NoError(t, err) + } + + require.Panics(t, func() { userDeclaration.Name() }) + require.Panics(t, func() { kindDeclaration.Name() }) + require.Panics(t, func() { unionDeclaration.Name() }) + require.Panics(t, func() { unionDeclaration.KindName() }) + require.NoError(t, generation.Freeze()) + require.Equal(t, "Value", userDeclaration.Name()) + require.Equal(t, "ValueKind", kindDeclaration.Name()) + require.Equal(t, "Value2", unionDeclaration.Name()) + require.Equal(t, "Value2Kind", unionDeclaration.KindName()) + require.Equal(t, "Value", types.Scope().GoTypeName(&expr.AttributeExpr{Type: userType})) + require.Equal(t, "ValueKind", types.Scope().GoTypeName(&expr.AttributeExpr{Type: kindUserType})) + require.Equal(t, "Value2", types.Scope().GoTypeName(&expr.AttributeExpr{Type: union})) + }) + } +} + +// TestGeneratedPackageLookupAcrossFreeze verifies that freeze keeps existing +// declarations readable and rejects every later declaration attempt. +func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + widget := generatedUserType("Widget", "widget") + union := generatedUnion("type", "value") + userDeclaration, err := types.DeclareUserType(widget) + require.NoError(t, err) + unionDeclaration, err := types.DeclareUnion(union) + require.NoError(t, err) + require.Panics(t, func() { unionDeclaration.Name() }) + + require.NoError(t, generation.Freeze()) + lookedUpUser, err := types.UserType(widget) + require.NoError(t, err) + require.Same(t, userDeclaration, lookedUpUser) + lookedUpUnion, err := types.Union(union) + require.NoError(t, err) + require.Same(t, unionDeclaration, lookedUpUnion) + require.Equal(t, "Value", lookedUpUnion.Name()) + require.Equal(t, "Widget", types.Scope().GoTypeName(&expr.AttributeExpr{Type: widget})) + require.Equal(t, "Value", types.Scope().GoTypeName(&expr.AttributeExpr{Type: union})) + require.Panics(t, func() { + types.Scope().Unique("Late") + }) + require.Panics(t, func() { + types.Scope().GoTypeName(&expr.AttributeExpr{Type: generatedUserType("Late", "late")}) + }) + + _, err = types.DeclareUserType(widget) + require.ErrorContains(t, err, "frozen") + _, err = types.DeclareUnion(union) + require.ErrorContains(t, err, "frozen") +} + +// TestGeneratedPackageRejectsConflictingOriginBindings verifies that exact +// and compiler-derived declarations cannot claim the same expression origin +// in either declaration order. +func TestGeneratedPackageRejectsConflictingOriginBindings(t *testing.T) { + for _, derivedFirst := range []bool{false, true} { + t.Run(fmt.Sprintf("derived first %t", derivedFirst), func(t *testing.T) { + root := RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + types := mustClaimTestPackage(t, generation, "generated.local/gen/values") + wrapper := root.Service("Values").Method("Read").Payload.Type.(expr.UserType) + identity, ok := generation.NormalizedMethodType(wrapper) + require.True(t, ok) + + if derivedFirst { + _, _, err := types.DeclareMethodType(identity, wrapper) + require.NoError(t, err) + _, err = types.DeclareUserType(wrapper) + require.ErrorContains(t, err, "already bound") + return + } + + _, err := types.DeclareUserType(wrapper) + require.NoError(t, err) + _, _, err = types.DeclareMethodType(identity, wrapper) + require.ErrorContains(t, err, "already bound") + }) + } +} + +// TestGenerationOwnsNormalizedWrapper verifies that normalization +// and declaration planning share the same closed method-role identity. +func TestGenerationOwnsNormalizedWrapper(t *testing.T) { + root := RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + wrapper := root.Service("Values").Method("Read").Payload.Type.(expr.UserType) + identity, ok := generation.NormalizedMethodType(wrapper) + + require.True(t, ok) + require.Equal(t, "ReadPayload", identity.Name()) + require.Equal(t, wrapper.ID(), identity.UID()) +} + +// TestGenerationAssignsExactMethodOwners verifies that every raw object role +// becomes a generated wrapper whose declaration and example identities agree. +func TestGenerationAssignsExactMethodOwners(t *testing.T) { + service := &expr.ServiceExpr{Name: "Values"} + method := &expr.MethodExpr{ + Name: "Stream", + Service: service, + Payload: &expr.AttributeExpr{Type: &expr.Object{}}, + StreamingPayload: &expr.AttributeExpr{Type: &expr.Object{}}, + Result: &expr.AttributeExpr{Type: &expr.Object{}}, + StreamingResult: &expr.AttributeExpr{Type: &expr.Object{}}, + } + service.Methods = []*expr.MethodExpr{method} + root := &expr.RootExpr{Services: []*expr.ServiceExpr{service}} + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + + cases := []struct { + name string + attribute *expr.AttributeExpr + expected expr.ExampleIdentity + }{ + {"payload", method.Payload, expr.MethodPayloadExampleIdentity(method)}, + {"streaming payload", method.StreamingPayload, expr.MethodStreamingPayloadExampleIdentity(method)}, + {"result", method.Result, expr.MethodResultExampleIdentity(method)}, + {"streaming result", method.StreamingResult, expr.MethodStreamingResultExampleIdentity(method)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wrapper := tc.attribute.Type.(expr.UserType) + exampleIdentity, generated := expr.GeneratedUserTypeExampleIdentity(wrapper) + require.True(t, generated) + require.Equal(t, tc.expected, exampleIdentity) + declarationIdentity, normalized := generation.NormalizedMethodType(wrapper) + require.True(t, normalized) + require.Equal(t, wrapper.ID(), declarationIdentity.UID()) + }) + } +} + +// TestMethodTypeIdentityPreservesRawOwner proves semantic wrapper identity does +// not collapse distinct DSL names that share one preferred Go spelling. +func TestMethodTypeIdentityPreservesRawOwner(t *testing.T) { + firstMethod := &expr.MethodExpr{Name: "foo-bar", Service: &expr.ServiceExpr{Name: "Values"}} + secondMethod := &expr.MethodExpr{Name: "foo_bar", Service: &expr.ServiceExpr{Name: "Values"}} + cases := []struct { + name string + kind derivedTypeKind + first expr.ExampleIdentity + second expr.ExampleIdentity + }{ + {"payload", methodPayloadTypeKind, expr.MethodPayloadExampleIdentity(firstMethod), expr.MethodPayloadExampleIdentity(secondMethod)}, + {"streaming payload", methodStreamingPayloadTypeKind, expr.MethodStreamingPayloadExampleIdentity(firstMethod), expr.MethodStreamingPayloadExampleIdentity(secondMethod)}, + {"result", methodResultTypeKind, expr.MethodResultExampleIdentity(firstMethod), expr.MethodResultExampleIdentity(secondMethod)}, + {"streaming result", methodStreamingResultTypeKind, expr.MethodStreamingResultExampleIdentity(firstMethod), expr.MethodStreamingResultExampleIdentity(secondMethod)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + first := newMethodTypeIdentity("test api", firstMethod.Name, tc.kind, tc.first) + second := newMethodTypeIdentity("test api", secondMethod.Name, tc.kind, tc.second) + + require.Equal(t, first.Name(), second.Name()) + require.NotEqual(t, first.UID(), second.UID()) + require.Equal(t, first.UID(), newMethodTypeIdentity("test api", firstMethod.Name, tc.kind, tc.first).UID()) + }) + } +} + +// TestMethodTypeNamesUseAPIToBreakTies verifies two APIs can write equal +// service and method wrapper names to one package without using input order. +func TestMethodTypeNamesUseAPIToBreakTies(t *testing.T) { + forwardFirst, forwardSecond := methodTypeNamesByAPI(t, false) + reverseFirst, reverseSecond := methodTypeNamesByAPI(t, true) + + require.Equal(t, "ReadPayload", forwardFirst) + require.Equal(t, "ReadPayload2", forwardSecond) + require.Equal(t, forwardFirst, reverseFirst) + require.Equal(t, forwardSecond, reverseSecond) +} + +// TestGenerationPreservesRawMethodOwner proves synthesized wrappers retain +// the raw method identity even when their preferred generated names coincide. +func TestGenerationPreservesRawMethodOwner(t *testing.T) { + root := RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("foo-bar", func() { + dsl.Payload(func() { + dsl.Attribute("first", dsl.String) + }) + }) + dsl.Method("foo_bar", func() { + dsl.Payload(func() { + dsl.Attribute("second", dsl.String) + }) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + first := root.Service("Values").Method("foo-bar").Payload.Type.(expr.UserType) + second := root.Service("Values").Method("foo_bar").Payload.Type.(expr.UserType) + firstIdentity, firstGenerated := generation.NormalizedMethodType(first) + secondIdentity, secondGenerated := generation.NormalizedMethodType(second) + + require.True(t, firstGenerated) + require.True(t, secondGenerated) + require.Equal(t, first.Name(), second.Name()) + require.NotEqual(t, first.ID(), second.ID()) + require.Equal(t, first.ID(), firstIdentity.UID()) + require.Equal(t, second.ID(), secondIdentity.UID()) +} + +// TestGenerationOwnsNormalizedMethodProvenance proves only wrappers created by +// this generation are classified as compiler-owned method types. Authored text +// that equals a synthesized UID is still an authored declaration. +func TestGenerationOwnsNormalizedMethodProvenance(t *testing.T) { + authoredMethod := &expr.MethodExpr{Name: "Authored", Service: &expr.ServiceExpr{Name: "Values"}} + authoredUID := "generated:" + expr.MethodPayloadExampleIdentity(authoredMethod).Seed() + root := RunDSL(t, func() { + authored := dsl.Type(authoredUID, func() { + dsl.Attribute("authored", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Authored", func() { + dsl.Payload(authored) + }) + dsl.Method("Raw", func() { + dsl.Payload(func() { + dsl.Attribute("raw", dsl.String) + }) + }) + }) + }) + generation, err := NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + + authored := root.Service("Values").Method("Authored").Payload.Type.(expr.UserType) + _, ok := generation.NormalizedMethodType(authored) + require.False(t, ok) + raw := root.Service("Values").Method("Raw").Payload.Type.(expr.UserType) + rawIdentity, ok := generation.NormalizedMethodType(raw) + require.True(t, ok) + require.Equal(t, raw.ID(), rawIdentity.UID()) +} + +// TestGenerationRecoversNormalizedMethodProvenance verifies that constructing +// another generation over the same evaluated root recognizes the exact typed +// wrapper instead of parsing its generated name or ID. +func TestGenerationRecoversNormalizedMethodProvenance(t *testing.T) { + root := RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + first := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + wrapper := root.Service("Values").Method("Read").Payload.Type.(expr.UserType) + firstIdentity, ok := first.NormalizedMethodType(wrapper) + require.True(t, ok) + + second := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + secondIdentity, ok := second.NormalizedMethodType(wrapper) + require.True(t, ok) + require.Equal(t, firstIdentity.UID(), secondIdentity.UID()) +} + +// TestGenerationCatalogsAreIsolated verifies that standalone generation runs +// do not share declaration records or name reservations. +func TestGenerationCatalogsAreIsolated(t *testing.T) { + firstGeneration := mustTestGeneration(t, "generated.local/gen", nil) + first := mustClaimTestPackage(t, firstGeneration, "generated.local/gen/types") + secondGeneration := mustTestGeneration(t, "generated.local/gen", nil) + second := mustClaimTestPackage(t, secondGeneration, "generated.local/gen/types") + firstUnion := generatedUnion("type", "value") + secondUnion := generatedUnion("type", "value") + + firstDeclaration, err := first.DeclareUnion(firstUnion) + require.NoError(t, err) + secondDeclaration, err := second.DeclareUnion(secondUnion) + require.NoError(t, err) + require.NoError(t, firstGeneration.Freeze()) + require.NoError(t, secondGeneration.Freeze()) + require.Equal(t, "Value", firstDeclaration.Name()) + require.Equal(t, "Value", secondDeclaration.Name()) + require.NotSame(t, firstDeclaration, secondDeclaration) + require.NotSame(t, first.Scope(), second.Scope()) +} + +// methodTypeNamesByAPI declares equal method wrappers in the requested order +// and returns the name assigned to each API. +func methodTypeNamesByAPI(t *testing.T, reverse bool) (string, string) { + t.Helper() + generation := mustTestGeneration(t, "generated.local/gen", nil) + generatedPackage := mustClaimTestPackage(t, generation, "generated.local/gen/shared") + method := &expr.MethodExpr{Name: "Read", Service: &expr.ServiceExpr{Name: "Shared"}} + example := expr.MethodPayloadExampleIdentity(method) + firstIdentity, firstWrapper := testMethodTypeWrapper("first api", method.Name, example) + secondIdentity, secondWrapper := testMethodTypeWrapper("second api", method.Name, example) + var first, second *TypeDeclaration + if reverse { + second = declareTestMethodType(t, generatedPackage, secondIdentity, secondWrapper) + first = declareTestMethodType(t, generatedPackage, firstIdentity, firstWrapper) + } else { + first = declareTestMethodType(t, generatedPackage, firstIdentity, firstWrapper) + second = declareTestMethodType(t, generatedPackage, secondIdentity, secondWrapper) + } + require.NoError(t, generation.Freeze()) + return first.Name(), second.Name() +} + +// testMethodTypeWrapper creates one generated method wrapper with an API name +// that is used only to order equal wrapper names. +func testMethodTypeWrapper(api, method string, example expr.ExampleIdentity) (MethodTypeIdentity, expr.UserType) { + identity := newMethodTypeIdentity(api, method, methodPayloadTypeKind, example) + wrapper := expr.NewGeneratedUserType( + identity.Name(), + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + example, + ) + return identity.bind(wrapper), wrapper +} + +// declareTestMethodType submits one generated wrapper and returns its stored +// type declaration. +func declareTestMethodType(t *testing.T, generatedPackage *GeneratedPackage, identity MethodTypeIdentity, wrapper expr.UserType) *TypeDeclaration { + t.Helper() + declaration, _, err := generatedPackage.DeclareMethodType(identity, wrapper) + require.NoError(t, err) + return declaration +} + +// generatedUserType builds a distinct user type for catalog tests. +func generatedUserType(name, id string) expr.UserType { + return generatedUserTypeOf(name, id, expr.String) +} + +// generatedUserTypeOf builds a distinct user type with the supplied shape. +func generatedUserTypeOf(name, id string, dataType expr.DataType) expr.UserType { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: dataType}, + TypeName: name, + UID: id, + } +} + +// generatedUnion builds a union whose emitted identity includes the supplied +// JSON envelope keys. +func generatedUnion(typeKey, valueKey string) *expr.Union { + return &expr.Union{ + TypeName: "Value", + TypeKey: typeKey, + ValueKey: valueKey, + } +} + +// generatedUnionWithBranch builds a union with one generated branch alias. +func generatedUnionWithBranch(aliasID string) (*expr.Union, expr.UserType) { + alias := generatedUserTypeOf("ValueText", aliasID, expr.String) + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{{ + Name: "text", + Attribute: &expr.AttributeExpr{Type: alias}, + }}, + }, alias +} + +// mustTestGeneration creates a generation for tests whose package root is +// known to be valid. +func mustTestGeneration(t *testing.T, genpkg string, roots []eval.Root) *Generation { + t.Helper() + generation, err := NewGeneration(genpkg, roots) + require.NoError(t, err) + return generation +} + +// mustClaimTestPackage claims a package for tests whose planner path is known +// to be valid and unique. +func mustClaimTestPackage(t *testing.T, generation *Generation, path string) *GeneratedPackage { + t.Helper() + generatedPackage, err := generation.ClaimPackage(path) + require.NoError(t, err) + return generatedPackage +} diff --git a/codegen/generation.go b/codegen/generation.go new file mode 100644 index 0000000000..d77311dc90 --- /dev/null +++ b/codegen/generation.go @@ -0,0 +1,347 @@ +// This file stores one evaluated design and every Go package and name produced +// from it. Goa chooses all generated and imported package names before writing +// source files. +package codegen + +import ( + "fmt" + "path" + "path/filepath" + "strings" + + "golang.org/x/mod/module" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // Generation stores the evaluated design roots and output packages used by + // one code generation run. + Generation struct { + genpkg string + roots []eval.Root + packages map[string]*GeneratedPackage + importOwners map[string]*GeneratedPackage + outputOwners map[string]*GeneratedPackage + methodTypes map[expr.UserType]MethodTypeIdentity + frozen bool + } +) + +// NewGeneration checks the generated package path and gives unnamed method +// payloads and results stable generated types. It updates the supplied designs, +// so callers must not prepare the same designs concurrently. +func NewGeneration(genpkg string, roots []eval.Root) (*Generation, error) { + canonicalGenPkg, err := canonicalGenerationRoot(genpkg) + if err != nil { + return nil, err + } + ownedRoots := append([]eval.Root(nil), roots...) + return &Generation{ + genpkg: canonicalGenPkg, + roots: ownedRoots, + packages: make(map[string]*GeneratedPackage), + importOwners: make(map[string]*GeneratedPackage), + outputOwners: make(map[string]*GeneratedPackage), + methodTypes: normalizeRoots(ownedRoots), + }, nil +} + +// NormalizedMethodType returns the generated name and payload-or-result role +// recorded for an unnamed method type. It returns false for a type declared +// directly in the design. +func (g *Generation) NormalizedMethodType(source expr.UserType) (MethodTypeIdentity, bool) { + identity, ok := g.methodTypes[source.Origin()] + return identity, ok +} + +// GenPkg returns the import path of the generated module root. +func (g *Generation) GenPkg() string { + return g.genpkg +} + +// Roots returns a copy of the evaluated design root slice used by this run. The +// returned roots still point to the prepared design values. +func (g *Generation) Roots() []eval.Root { + return append([]eval.Root(nil), g.roots...) +} + +// ClaimPackage records path as a generated package and returns the package's +// name records. Repeating the same path returns the same package. A different +// path that resolves to the same import path or output directory returns an +// error. +func (g *Generation) ClaimPackage(path string) (*GeneratedPackage, error) { + if g.frozen { + return nil, fmt.Errorf("generated package %q cannot be claimed after generation freeze", path) + } + canonicalPath, err := canonicalGeneratedPackagePath(g.genpkg, path) + if err != nil { + return nil, err + } + outputDir, err := generatedOutputDirectory(g.genpkg, canonicalPath) + if err != nil { + return nil, err + } + return g.claimOutputPackage(path, canonicalPath, outputDir) +} + +// ClaimOutputPackage records a Go package written to outputDirectory, relative +// to the working directory. It supports generated files, such as starter +// implementations, that are written outside GenPkg but still need their names +// finalized with the other generated packages. +func (g *Generation) ClaimOutputPackage(importPath, outputDirectory string) (*GeneratedPackage, error) { + if g.frozen { + return nil, fmt.Errorf("output package %q cannot be claimed after generation freeze", importPath) + } + canonicalPath, err := canonicalOutputPackagePath(importPath) + if err != nil { + return nil, err + } + canonicalDirectory, err := canonicalOutputDirectory(outputDirectory) + if err != nil { + return nil, err + } + return g.claimOutputPackage(importPath, canonicalPath, canonicalDirectory) +} + +// claimOutputPackage records a package after its caller has checked the import +// path and output directory. +func (g *Generation) claimOutputPackage(claim, canonicalPath, outputDir string) (*GeneratedPackage, error) { + if generatedPackage, ok := g.packages[claim]; ok { + if generatedPackage.outputDir != outputDir { + return nil, fmt.Errorf( + "generated package %q is already mapped to output directory %q, not %q", + claim, + generatedPackage.outputDir, + outputDir, + ) + } + return generatedPackage, nil + } + if owner, ok := g.importOwners[canonicalPath]; ok { + return nil, fmt.Errorf( + "generated package paths %q and %q normalize to import path %q", + owner.claim, + claim, + canonicalPath, + ) + } + for existingDir, owner := range g.outputOwners { + if strings.EqualFold(existingDir, outputDir) { + return nil, fmt.Errorf( + "generated package paths %q and %q resolve to output directory %q on a case-insensitive filesystem", + owner.claim, + claim, + outputDir, + ) + } + } + generatedPackage := newGeneratedPackage(claim, canonicalPath, outputDir) + g.packages[claim] = generatedPackage + g.importOwners[canonicalPath] = generatedPackage + g.outputOwners[outputDir] = generatedPackage + return generatedPackage, nil +} + +// Package returns the package previously recorded for canonicalPath, which +// must be a cleaned import path. It panics when the path is not clean or was +// not recorded before Freeze. +func (g *Generation) Package(canonicalPath string) *GeneratedPackage { + cleaned, err := canonicalGeneratedPackagePath(g.genpkg, canonicalPath) + if err != nil || cleaned != canonicalPath { + panic(fmt.Sprintf("generated package lookup path %q is not canonical", canonicalPath)) + } + generatedPackage, ok := g.importOwners[canonicalPath] + if !ok { + panic(fmt.Sprintf("generated package %q was not claimed during planning", canonicalPath)) + } + return generatedPackage +} + +// Freeze assigns every generated declaration and imported package its final Go +// name. It then rejects new packages, declarations, and name requests while +// keeping the completed records available for source generation. +func (g *Generation) Freeze() error { + if g.frozen { + return nil + } + for _, generatedPackage := range g.packages { + if err := generatedPackage.freeze(); err != nil { + return err + } + } + g.frozen = true + return nil +} + +// Frozen reports whether all generated and imported package names are final. +func (g *Generation) Frozen() bool { + return g.frozen +} + +// OwnsName reports whether DeclareName added declaration to a package in this +// generation. It can return true before Freeze chooses the declaration's final +// Go name. +func (g *Generation) OwnsName(declaration *NameDeclaration) bool { + if declaration == nil || declaration.owner == nil { + return false + } + return g.importOwners[declaration.owner.path] == declaration.owner +} + +// PackageForFile returns the generated package that writes outputPath. The +// second result is false when no package claimed the file's directory during +// planning or when outputPath is not a valid relative output path. +func (g *Generation) PackageForFile(outputPath string) (*GeneratedPackage, bool) { + directory, err := canonicalOutputDirectory(path.Dir(filepath.ToSlash(outputPath))) + if err != nil { + return nil, false + } + pkg, ok := g.outputOwners[directory] + return pkg, ok +} + +// ImportPath returns the cleaned Go import path for the package. +func (p *GeneratedPackage) ImportPath() string { + return p.path +} + +// OutputDirectory returns the cleaned directory, relative to the working +// directory, where this package's files are written. +func (p *GeneratedPackage) OutputDirectory() string { + return p.outputDir +} + +// OwnsName reports whether declaration was added to this exact generated +// package. A package with the same import path in another generation does not +// own the declaration. +func (p *GeneratedPackage) OwnsName(declaration *NameDeclaration) bool { + return declaration != nil && declaration.owner == p +} + +// The generated module import prefix is checked and cleaned here. Tests may +// pass dot or slash to request local output paths. +func canonicalGenerationRoot(genpkg string) (string, error) { + if genpkg == "." || genpkg == "/" { + return genpkg, nil + } + canonical, err := cleanImportPath("generated package root", genpkg) + if err != nil { + return "", err + } + if canonical == "." || canonical == "/" { + return "", fmt.Errorf("generated package root %q is invalid", genpkg) + } + if err := module.CheckImportPath(canonical); err != nil { + return "", fmt.Errorf("generated package root %q is invalid: %w", genpkg, err) + } + return canonical, nil +} + +// A generated package import path is checked and cleaned here before it is +// written in generated source. +func canonicalGeneratedPackagePath(genpkg, importPath string) (string, error) { + canonical, err := cleanImportPath("generated package path", importPath) + if err != nil { + return "", err + } + validated := canonical + if genpkg == "/" { + validated = strings.TrimPrefix(canonical, "/") + if validated == "" { + return canonical, nil + } + } else if genpkg == "." && canonical == "." { + return canonical, nil + } + if err := module.CheckImportPath(validated); err != nil { + return "", fmt.Errorf("generated package path %q is invalid: %w", importPath, err) + } + return canonical, nil +} + +// A package written outside GenPkg has its import path checked and cleaned +// here. +func canonicalOutputPackagePath(importPath string) (string, error) { + canonical, err := cleanImportPath("output package path", importPath) + if err != nil { + return "", err + } + if err := module.CheckImportPath(canonical); err != nil { + return "", fmt.Errorf("output package path %q is invalid: %w", importPath, err) + } + return canonical, nil +} + +// A relative output directory is cleaned here. Paths that escape the working +// directory or use platform-dependent separators are rejected. +func canonicalOutputDirectory(outputDirectory string) (string, error) { + if strings.Contains(outputDirectory, "\\") { + return "", fmt.Errorf("output directory %q contains a backslash", outputDirectory) + } + if path.IsAbs(outputDirectory) { + return "", fmt.Errorf("output directory %q must be relative", outputDirectory) + } + if strings.Contains(outputDirectory, ":") { + return "", fmt.Errorf("output directory %q is not portable", outputDirectory) + } + canonical := path.Clean(outputDirectory) + if canonical == ".." || strings.HasPrefix(canonical, "../") { + return "", fmt.Errorf("output directory %q escapes the generation working directory", outputDirectory) + } + return canonical, nil +} + +// cleanImportPath rejects backslashes and removes dot segments from a Go import +// path. Errors include the original path supplied by the caller. +func cleanImportPath(label, importPath string) (string, error) { + if strings.Contains(importPath, "\\") { + return "", fmt.Errorf("%s %q contains a backslash", label, importPath) + } + return path.Clean(importPath), nil +} + +// generatedOutputDirectory returns the directory under gen for importPath. It +// returns an error when importPath is outside genpkg. +func generatedOutputDirectory(genpkg, importPath string) (string, error) { + var relative string + switch genpkg { + case "/": + if !strings.HasPrefix(importPath, "/") { + return "", fmt.Errorf( + "generated package %q is outside generated import root %q", + importPath, + genpkg, + ) + } + relative = strings.TrimPrefix(importPath, "/") + case ".": + if path.IsAbs(importPath) || importPath == ".." || strings.HasPrefix(importPath, "../") { + return "", fmt.Errorf( + "generated package %q is outside generated import root %q", + importPath, + genpkg, + ) + } + relative = importPath + default: + if importPath != genpkg && !strings.HasPrefix(importPath, genpkg+"/") { + return "", fmt.Errorf( + "generated package %q is outside generated import root %q", + importPath, + genpkg, + ) + } + relative = strings.TrimPrefix(importPath, genpkg) + relative = strings.TrimPrefix(relative, "/") + } + if relative == ".." || strings.HasPrefix(relative, "../") { + return "", fmt.Errorf( + "generated package %q is outside generated import root %q", + importPath, + genpkg, + ) + } + return canonicalOutputDirectory(path.Join(Gendir, relative)) +} diff --git a/codegen/generator/attached_jsonrpc_sse_integration_test.go b/codegen/generator/attached_jsonrpc_sse_integration_test.go new file mode 100644 index 0000000000..3af92ada7b --- /dev/null +++ b/codegen/generator/attached_jsonrpc_sse_integration_test.go @@ -0,0 +1,112 @@ +// This file checks that services added by plugins receive every declaration +// needed by their generated JSON-RPC server-sent-event code. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestAttachedJSONRPCSSEServiceCompiles runs every generation step after a +// plugin adds one method that returns a value and another that streams values. +func TestAttachedJSONRPCSSEServiceCompiles(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("attached-stream", func() {}) + }) + registry := newDefaultRegistry() + registry.registerPlugin("attached-stream", "gen", pluginNormal, func() Plugin { + return Plugin{Prepare: func(_ string, roots []eval.Root) error { + return attachJSONRPCSSEService(roots[0].(*expr.RootExpr)) + }} + }) + run, err := newGenerationRun("gen", registry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + files, err := mergeFilesByPath(result.files) + require.NoError(t, err) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + serviceCode, err := os.ReadFile(filepath.Join(dir, "gen", "attached_stream", "service.go")) + require.NoError(t, err) + generated := string(serviceCode) + require.Contains(t, generated, "Watch(context.Context, WatchServerStream) (err error)") + require.Contains(t, generated, "Send(string) error") + require.Contains(t, generated, "SendWithContext(context.Context, string) error") + require.Contains(t, generated, "Close() error") + require.NotContains(t, generated, "SendAndClose") + require.NotContains(t, generated, "SendError") + require.NotContains(t, generated, "RequestID") + require.NotContains(t, generated, "isWatchEvent") + require.NotContains(t, generated, "Send(ctx context.Context") + runGeneratedTests(t, dir) +} + +// attachJSONRPCSSEService adds one evaluated service and its JSON-RPC route to +// a design that completed DSL evaluation before the plugin ran. +func attachJSONRPCSSEService(root *expr.RootExpr) error { + read := &expr.MethodExpr{ + Name: "Read", + Payload: &expr.AttributeExpr{Type: expr.Empty}, + Result: &expr.AttributeExpr{Type: expr.String}, + Meta: expr.MetaExpr{"jsonrpc": []string{}}, + } + watch := &expr.MethodExpr{ + Name: "Watch", + Payload: &expr.AttributeExpr{Type: expr.Empty}, + StreamingResult: &expr.AttributeExpr{Type: expr.String}, + Stream: expr.ServerStreamKind, + Meta: expr.MetaExpr{"jsonrpc": []string{}}, + } + service := &expr.ServiceExpr{ + Name: "AttachedStream", + Methods: []*expr.MethodExpr{read, watch}, + Meta: expr.MetaExpr{"jsonrpc:service": []string{}}, + } + read.Service = service + watch.Service = service + + transport := &expr.HTTPServiceExpr{ + ServiceExpr: service, + JSONRPCRoute: &expr.RouteExpr{ + Method: "POST", + Path: "/rpc", + }, + SSE: &expr.HTTPSSEExpr{}, + } + transport.Root = &root.API.JSONRPC.HTTPExpr + transport.JSONRPCRoute.Endpoint = &expr.HTTPEndpointExpr{Service: transport} + for _, method := range service.Methods { + endpoint := &expr.HTTPEndpointExpr{ + MethodExpr: method, + Service: transport, + Body: method.Payload, + Params: expr.NewEmptyMappedAttributeExpr(), + Headers: expr.NewEmptyMappedAttributeExpr(), + Cookies: expr.NewEmptyMappedAttributeExpr(), + Meta: expr.MetaExpr{"jsonrpc": []string{}}, + } + if method.IsResultStreaming() { + endpoint.SSE = &expr.HTTPSSEExpr{} + } + endpoint.Routes = []*expr.RouteExpr{{Method: "POST", Path: "/rpc", Endpoint: endpoint}} + transport.HTTPEndpoints = append(transport.HTTPEndpoints, endpoint) + } + + root.Services = append(root.Services, service) + root.API.JSONRPC.Services = append(root.API.JSONRPC.Services, transport) + return root.EvaluateAttachedServices([]*expr.ServiceExpr{service}) +} diff --git a/codegen/generator/command_isolation_test.go b/codegen/generator/command_isolation_test.go new file mode 100644 index 0000000000..4fc1278201 --- /dev/null +++ b/codegen/generator/command_isolation_test.go @@ -0,0 +1,233 @@ +// This file checks that each command builds files from its own Plan and that +// simultaneous commands cannot change each other's output. +package generator + +import ( + "os" + "path/filepath" + "reflect" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // generatorCall records the Plan passed to one command's two functions. + generatorCall struct { + planCalls int + generateCalls int + planned *Plan + generated *Plan + } + + // commandResult contains every file written by one command, indexed by its + // path beneath the output directory. + commandResult struct { + files map[string][]byte + err error + } +) + +// TestCommandsUseEachSelectedGeneratorOnce checks that each command calls only +// its listed generators and passes the same Plan to both functions. +func TestCommandsUseEachSelectedGeneratorOnce(t *testing.T) { + root := codegen.RunDSL(t, commandIsolationDSL("first")) + cases := []struct { + name string + factories []generatorFactory + selected []string + }{ + {"gen", genGeneratorFactories(), []string{"service", "transport", "openapi"}}, + {"example", exampleGeneratorFactories(), []string{"example"}}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + calls := make(map[string]*generatorCall, len(test.selected)) + registry := newRegistry() + registry.addCommand(test.name, observedGenerators(test.factories, calls)...) + + run, err := newGenerationRun(test.name, registry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + + require.ElementsMatch(t, test.selected, mapKeys(calls)) + for _, name := range test.selected { + call := calls[name] + require.Equal(t, 1, call.planCalls, "%s plan calls", name) + require.Equal(t, 1, call.generateCalls, "%s file calls", name) + require.Same(t, result.plan, call.planned, "%s planned Plan", name) + require.Same(t, call.planned, call.generated, "%s generated Plan", name) + } + }) + } +} + +// TestFocusedCommandDoesNotBuildExamplesOrOpenAPI checks that a command with no +// example or OpenAPI generator creates neither result. +func TestFocusedCommandDoesNotBuildExamplesOrOpenAPI(t *testing.T) { + root := codegen.RunDSL(t, commandIsolationDSL("focused")) + registry := testRegistry("focused", testGenerator(nil, nil)) + run, err := newGenerationRun("focused", registry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.Nil(t, result.plan.example) + require.Nil(t, result.plan.openapi) +} + +// TestCommandsProduceTheSameFilesWhenRunTogether checks that gen and example +// produce the same bytes alone, repeatedly, and beside another command run. +func TestCommandsProduceTheSameFilesWhenRunTogether(t *testing.T) { + for _, command := range []string{"gen", "example"} { + t.Run(command, func(t *testing.T) { + first := codegen.RunDSL(t, commandIsolationDSL("first")) + second := codegen.RunDSL(t, commandIsolationDSL("second")) + firstExpected, err := renderCommand(command, first, t.TempDir()) + require.NoError(t, err) + firstAgain, err := renderCommand(command, first, t.TempDir()) + require.NoError(t, err) + require.Equal(t, firstExpected, firstAgain) + secondExpected, err := renderCommand(command, second, t.TempDir()) + require.NoError(t, err) + + start := make(chan struct{}) + results := make(chan commandResult, 2) + var ready sync.WaitGroup + ready.Add(2) + for _, input := range []struct { + root *expr.RootExpr + dir string + }{{first, t.TempDir()}, {second, t.TempDir()}} { + go runCommandTogether(input.root, input.dir, command, start, &ready, results) + } + ready.Wait() + close(start) + firstResult := <-results + secondResult := <-results + require.NoError(t, firstResult.err) + require.NoError(t, secondResult.err) + if !reflect.DeepEqual(firstResult.files, firstExpected) { + firstResult, secondResult = secondResult, firstResult + } + require.Equal(t, firstExpected, firstResult.files) + require.Equal(t, secondExpected, secondResult.files) + }) + } +} + +// observedGenerators wraps each selected generator and records the Plan passed +// to the function that chooses names and the function that builds files. +func observedGenerators(factories []generatorFactory, calls map[string]*generatorCall) []generatorFactory { + observed := make([]generatorFactory, len(factories)) + for index, factory := range factories { + generator := factory() + call := &generatorCall{} + calls[generator.name] = call + observed[index] = observedGenerator(generator, call) + } + return observed +} + +// observedGenerator returns a factory that records calls before running the +// selected generator's original functions. +func observedGenerator(generator coreGenerator, call *generatorCall) generatorFactory { + return func() coreGenerator { + return coreGenerator{ + name: generator.name, + Plan: func(plan *Plan) error { + call.planCalls++ + call.planned = plan + return generator.Plan(plan) + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + call.generateCalls++ + call.generated = plan + return generator.Generate(plan) + }, + } + } +} + +// mapKeys returns the generator names recorded by one command. +func mapKeys(calls map[string]*generatorCall) []string { + keys := make([]string, 0, len(calls)) + for key := range calls { + keys = append(keys, key) + } + return keys +} + +// renderCommand builds and writes every file for one command. +func renderCommand(command string, root *expr.RootExpr, dir string) (map[string][]byte, error) { + run, err := newGenerationRun(command, newDefaultRegistry()) + if err != nil { + return nil, err + } + result, err := run.execute("generated.local/gen", []eval.Root{root}) + if err != nil { + return nil, err + } + files, err := mergeFilesByPath(result.files) + if err != nil { + return nil, err + } + written := make(map[string][]byte, len(files)) + for _, file := range files { + filename, err := file.Render(dir) + if err != nil { + return nil, err + } + content, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + relative, err := filepath.Rel(dir, filename) + if err != nil { + return nil, err + } + written[filepath.ToSlash(relative)] = content + } + return written, nil +} + +// runCommandTogether waits until both command runs are ready, then builds and +// writes one command's files. +func runCommandTogether(root *expr.RootExpr, dir, command string, start <-chan struct{}, ready *sync.WaitGroup, results chan<- commandResult) { + ready.Done() + <-start + files, err := renderCommand(command, root, dir) + results <- commandResult{files: files, err: err} +} + +// commandIsolationDSL defines one HTTP service whose names identify its output. +func commandIsolationDSL(name string) func() { + return func() { + serviceName := name + " service" + dsl.API(name, func() { + dsl.Server(name, func() { + dsl.Services(serviceName) + dsl.Host(name, func() { + dsl.URI("http://localhost") + }) + }) + }) + dsl.Service(serviceName, func() { + dsl.Method("show", func() { + dsl.Payload(func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/show") + }) + }) + }) + } +} diff --git a/codegen/generator/design_snapshot.go b/codegen/generator/design_snapshot.go new file mode 100644 index 0000000000..0f24512d0d --- /dev/null +++ b/codegen/generator/design_snapshot.go @@ -0,0 +1,511 @@ +// This file records the prepared design so Goa can report if a generator +// changes it. Map entries are sorted so repeated runs report the same first +// changed field. +package generator + +import ( + "fmt" + "math" + "reflect" + "runtime" + "slices" + "strconv" + "strings" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // designSnapshot stores the prepared design values for one run. + designSnapshot struct { + states []designState + references []reflect.Value + } + + // designState records one scalar, container, or reference reached at path. + designState struct { + path string + typ reflect.Type + value string + } + + // designSnapshotter records each design value once, even when pointers form + // a cycle or several fields point to the same value. + designSnapshotter struct { + states []designState + references []reflect.Value + visited map[designVisit]struct{} + } + + // designVisit identifies one pointer, map, or slice. Slice capacity separates + // slices that can reach different parts of the same underlying array. + designVisit struct { + typ reflect.Type + kind reflect.Kind + ptr uintptr + extent int + } + + // mapSnapshotEntry stores one map pair after the entries are sorted. + mapSnapshotEntry struct { + key reflect.Value + value reflect.Value + keyOrder mapOrderValue + order mapOrderValue + } + + // mapOrderValue stores enough of a map key or value to sort entries. Pointers + // are compared by address and other values by their exact contents. + mapOrderValue struct { + typ reflect.Type + kind reflect.Kind + isNil bool + boolean bool + integer int64 + unsigned uint64 + text string + reference uintptr + length int + capacity int + children []mapOrderValue + } +) + +var typeMapType = reflect.TypeFor[expr.TypeMap]() + +// snapshotPreparedDesign records every value reachable from roots after the +// designs have been prepared. +func snapshotPreparedDesign(roots []eval.Root) (*designSnapshot, error) { + snapshotter := &designSnapshotter{visited: make(map[designVisit]struct{})} + for i, root := range roots { + if err := snapshotter.appendValue(fmt.Sprintf("roots[%d]", i), reflect.ValueOf(root)); err != nil { + return nil, err + } + } + return &designSnapshot{ + states: snapshotter.states, + references: snapshotter.references, + }, nil +} + +// orderedMapEntries returns map entries in an order that does not depend on +// how Go stores the map. +func orderedMapEntries(value reflect.Value) ([]mapSnapshotEntry, error) { + entries := make([]mapSnapshotEntry, 0, value.Len()) + iterator := value.MapRange() + for iterator.Next() { + key := iterator.Key() + mapValue := iterator.Value() + keyOrder, err := mapValueOrder(key) + if err != nil { + return nil, err + } + valueOrder, err := mapValueOrder(mapValue) + if err != nil { + return nil, err + } + entries = append(entries, mapSnapshotEntry{ + key: key, + value: mapValue, + keyOrder: keyOrder, + order: valueOrder, + }) + } + if err := validateMapOrderTypes(entries); err != nil { + return nil, err + } + slices.SortFunc(entries, func(left, right mapSnapshotEntry) int { + if compared := compareMapOrderValue(left.keyOrder, right.keyOrder); compared != 0 { + return compared + } + return compareMapOrderValue(left.order, right.order) + }) + return entries, nil +} + +// validateMapOrderTypes rejects two runtime types that print the same name but +// cannot be compared. Treating them as equal would make map order vary by run. +func validateMapOrderTypes(entries []mapSnapshotEntry) error { + for i := range entries { + for j := i + 1; j < len(entries); j++ { + if err := validateMapOrderType(entries[i].keyOrder, entries[j].keyOrder); err != nil { + return err + } + if err := validateMapOrderType(entries[i].order, entries[j].order); err != nil { + return err + } + } + } + return nil +} + +// validateMapOrderType checks a runtime type and the concrete values stored in +// interface map keys and values. +func validateMapOrderType(left, right mapOrderValue) error { + if left.typ != right.typ && stableTypeName(left.typ) == stableTypeName(right.typ) { + return fmt.Errorf("cannot deterministically order distinct reflected map types %q", stableTypeName(left.typ)) + } + common := min(len(left.children), len(right.children)) + for i := range common { + if err := validateMapOrderType(left.children[i], right.children[i]); err != nil { + return err + } + } + return nil +} + +// mapValueOrder records enough of a map key or value to sort entries without +// reading through pointers. +func mapValueOrder(value reflect.Value) (mapOrderValue, error) { + if !value.IsValid() { + return mapOrderValue{}, nil + } + order := mapOrderValue{typ: value.Type(), kind: value.Kind()} + switch value.Kind() { + case reflect.Bool: + order.boolean = value.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + order.integer = value.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + order.unsigned = value.Uint() + case reflect.Float32: + order.unsigned = uint64(math.Float32bits(float32(value.Float()))) + case reflect.Float64: + order.unsigned = math.Float64bits(value.Float()) + case reflect.Complex64: + complexValue := complex64(value.Complex()) + order.children = []mapOrderValue{ + {unsigned: uint64(math.Float32bits(real(complexValue)))}, + {unsigned: uint64(math.Float32bits(imag(complexValue)))}, + } + case reflect.Complex128: + complexValue := value.Complex() + order.children = []mapOrderValue{ + {unsigned: math.Float64bits(real(complexValue))}, + {unsigned: math.Float64bits(imag(complexValue))}, + } + case reflect.String: + order.text = value.String() + case reflect.Interface: + if value.IsNil() { + order.isNil = true + break + } + inner, err := mapValueOrder(value.Elem()) + if err != nil { + return mapOrderValue{}, err + } + order.children = []mapOrderValue{inner} + case reflect.Pointer, reflect.Chan: + if value.IsNil() { + order.isNil = true + break + } + order.reference = value.Pointer() + case reflect.UnsafePointer: + order.reference = value.Pointer() + case reflect.Slice: + if value.IsNil() { + order.isNil = true + break + } + order.reference = value.Pointer() + order.length = value.Len() + order.capacity = value.Cap() + case reflect.Map: + if value.IsNil() { + order.isNil = true + break + } + order.reference = value.Pointer() + order.length = value.Len() + case reflect.Func: + if value.IsNil() { + order.isNil = true + break + } + order.reference = value.Pointer() + case reflect.Struct: + order.children = make([]mapOrderValue, value.NumField()) + for i := range value.NumField() { + field, err := mapValueOrder(value.Field(i)) + if err != nil { + return mapOrderValue{}, err + } + order.children[i] = field + } + case reflect.Array: + order.children = make([]mapOrderValue, value.Len()) + for i := range value.Len() { + element, err := mapValueOrder(value.Index(i)) + if err != nil { + return mapOrderValue{}, err + } + order.children[i] = element + } + default: + return mapOrderValue{}, fmt.Errorf("cannot order map %s value", value.Kind()) + } + return order, nil +} + +// compareMapOrderValue sorts the recorded runtime values. +func compareMapOrderValue(left, right mapOrderValue) int { + if left.typ != right.typ { + return strings.Compare(stableTypeName(left.typ), stableTypeName(right.typ)) + } + if left.kind != right.kind { + return int(left.kind) - int(right.kind) + } + if left.isNil != right.isNil { + if left.isNil { + return -1 + } + return 1 + } + if left.boolean != right.boolean { + if !left.boolean { + return -1 + } + return 1 + } + if left.integer != right.integer { + if left.integer < right.integer { + return -1 + } + return 1 + } + if left.unsigned != right.unsigned { + if left.unsigned < right.unsigned { + return -1 + } + return 1 + } + if compared := strings.Compare(left.text, right.text); compared != 0 { + return compared + } + if left.reference != right.reference { + if left.reference < right.reference { + return -1 + } + return 1 + } + if left.length != right.length { + return left.length - right.length + } + if left.capacity != right.capacity { + return left.capacity - right.capacity + } + common := min(len(left.children), len(right.children)) + for i := range common { + if compared := compareMapOrderValue(left.children[i], right.children[i]); compared != 0 { + return compared + } + } + return len(left.children) - len(right.children) +} + +// stableTypeName returns the package path and name used to sort runtime types. +func stableTypeName(typ reflect.Type) string { + if typ == nil { + return "" + } + return typ.PkgPath() + ":" + typ.String() +} + +// mapEntryPath returns the field path shown when a map entry changes. +func mapEntryPath(path string, index int, key reflect.Value) string { + if key.Kind() == reflect.String { + return path + "[" + strconv.Quote(key.String()) + "]" + } + return fmt.Sprintf("%s{%d}", path, index) +} + +// formatPointer returns a pointer address as text for a change report. +func formatPointer(pointer uintptr) string { + return "0x" + strconv.FormatUint(uint64(pointer), 16) +} + +// changedPath returns the first design field that differs from the saved copy. +func (s *designSnapshot) changedPath(roots []eval.Root) (string, error) { + defer runtime.KeepAlive(s.references) + + current, err := snapshotPreparedDesign(roots) + if err != nil { + return "", err + } + common := min(len(s.states), len(current.states)) + for i := range common { + if s.states[i] != current.states[i] { + return current.states[i].path, nil + } + } + if len(s.states) > common { + return s.states[common].path, nil + } + if len(current.states) > common { + return current.states[common].path, nil + } + return "", nil +} + +// appendValue records value and recursively records all state it can reach. +func (s *designSnapshotter) appendValue(path string, value reflect.Value) error { + if !value.IsValid() { + s.append(path, nil, "invalid") + return nil + } + typ := value.Type() + switch value.Kind() { + case reflect.Bool: + s.append(path, typ, strconv.FormatBool(value.Bool())) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + s.append(path, typ, strconv.FormatInt(value.Int(), 10)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + s.append(path, typ, strconv.FormatUint(value.Uint(), 10)) + case reflect.Float32: + s.append(path, typ, strconv.FormatUint(uint64(math.Float32bits(float32(value.Float()))), 16)) + case reflect.Float64: + s.append(path, typ, strconv.FormatUint(math.Float64bits(value.Float()), 16)) + case reflect.Complex64: + complexValue := complex64(value.Complex()) + s.append(path, typ, fmt.Sprintf("%x:%x", math.Float32bits(real(complexValue)), math.Float32bits(imag(complexValue)))) + case reflect.Complex128: + complexValue := value.Complex() + s.append(path, typ, fmt.Sprintf("%x:%x", math.Float64bits(real(complexValue)), math.Float64bits(imag(complexValue)))) + case reflect.String: + s.append(path, typ, value.String()) + case reflect.Interface: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + s.append(path, typ, value.Elem().Type().String()) + return s.appendValue(path, value.Elem()) + case reflect.Pointer: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + pointer := value.Pointer() + s.references = append(s.references, value) + s.append(path, typ, formatPointer(pointer)) + if s.seen(designVisit{typ: typ, kind: value.Kind(), ptr: pointer}) { + return nil + } + return s.appendValue(path, value.Elem()) + case reflect.Struct: + s.append(path, typ, "struct") + if typ == typeMapType { + if err := s.appendValue(path+".User", value.FieldByName("User")); err != nil { + return err + } + s.appendExternalType(path+".External", value.FieldByName("External")) + return nil + } + for i := range value.NumField() { + if err := s.appendValue(path+"."+typ.Field(i).Name, value.Field(i)); err != nil { + return err + } + } + case reflect.Array: + s.append(path, typ, strconv.Itoa(value.Len())) + for i := range value.Len() { + if err := s.appendValue(fmt.Sprintf("%s[%d]", path, i), value.Index(i)); err != nil { + return err + } + } + case reflect.Slice: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + pointer := value.Pointer() + s.references = append(s.references, value) + s.append(path, typ, fmt.Sprintf("%s:%d:%d", formatPointer(pointer), value.Len(), value.Cap())) + visit := designVisit{typ: typ, kind: value.Kind(), ptr: pointer, extent: value.Cap()} + if s.seen(visit) { + return nil + } + reachable := value.Slice(0, value.Cap()) + for i := range reachable.Len() { + if err := s.appendValue(fmt.Sprintf("%s[%d]", path, i), reachable.Index(i)); err != nil { + return err + } + } + case reflect.Map: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + pointer := value.Pointer() + s.references = append(s.references, value) + s.append(path, typ, fmt.Sprintf("%s:%d", formatPointer(pointer), value.Len())) + if s.seen(designVisit{typ: typ, kind: value.Kind(), ptr: pointer}) { + return nil + } + entries, err := orderedMapEntries(value) + if err != nil { + return fmt.Errorf("snapshot prepared design at %s: %w", path, err) + } + for i, entry := range entries { + entryPath := mapEntryPath(path, i, entry.key) + if err := s.appendValue(entryPath+".key", entry.key); err != nil { + return err + } + if err := s.appendValue(entryPath, entry.value); err != nil { + return err + } + } + case reflect.Func: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + // Design evaluation has finished, so functions stored by Goa or a plugin + // will not run here. Record their type and pointer address without invoking + // them. + s.append(path, typ, formatPointer(value.Pointer())) + case reflect.Chan: + if !value.IsNil() { + return fmt.Errorf("snapshot prepared design at %s: unsupported non-nil channel %s", path, typ) + } + s.append(path, typ, "nil") + case reflect.UnsafePointer: + if value.Pointer() != 0 { + return fmt.Errorf("snapshot prepared design at %s: unsupported non-nil unsafe pointer %s", path, typ) + } + s.append(path, typ, "nil") + default: + return fmt.Errorf("snapshot prepared design at %s: unsupported %s value", path, value.Kind()) + } + return nil +} + +// appendExternalType records the concrete Go type of a conversion example. +// Generators do not read fields or other runtime state from that value. +func (s *designSnapshotter) appendExternalType(path string, value reflect.Value) { + if value.IsNil() { + s.append(path, value.Type(), "nil") + return + } + s.append(path, value.Elem().Type(), "external exemplar type") +} + +// append adds one comparable state entry to the traversal. +func (s *designSnapshotter) append(path string, typ reflect.Type, value string) { + s.states = append(s.states, designState{path: path, typ: typ, value: value}) +} + +// seen records a reference and reports whether this exact node was already traversed. +func (s *designSnapshotter) seen(visit designVisit) bool { + if _, ok := s.visited[visit]; ok { + return true + } + s.visited[visit] = struct{}{} + return false +} + +// orderedMapEntries returns map pairs in the same order on every run without +// reading through pointers stored in the map. diff --git a/codegen/generator/design_snapshot_test.go b/codegen/generator/design_snapshot_test.go new file mode 100644 index 0000000000..0881ddbd12 --- /dev/null +++ b/codegen/generator/design_snapshot_test.go @@ -0,0 +1,185 @@ +// This file verifies that the prepared-design snapshot reports persistent +// semantic mutations made after the lifecycle's explicit preparation phase. +package generator + +import ( + "testing" + "unsafe" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestPreparedDesignSnapshotRejectsUnsupportedState proves live behavior not +// owned by the evaluated design cannot enter the persistent mutation audit. +func TestPreparedDesignSnapshotRejectsUnsupportedState(t *testing.T) { + value := 1 + tests := []struct { + name string + value any + want string + }{ + {"channel", make(chan int), "unsupported non-nil channel"}, + {"unsafe pointer", unsafe.Pointer(&value), "unsupported non-nil unsafe pointer"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: test.value, + }, + }}} + _, err := snapshotPreparedDesign([]eval.Root{root}) + require.ErrorContains(t, err, "roots[0].Types[0].AttributeExpr.DefaultValue") + require.ErrorContains(t, err, test.want) + }) + } +} + +// TestPreparedDesignSnapshotTracksFunctions proves unchanged functions remain +// valid while replacement and nilness changes are reported as mutations. +func TestPreparedDesignSnapshotTracksFunctions(t *testing.T) { + first := func(string) string { return "first" } + second := func(string) string { return "second" } + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: first, + }, + }}} + + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Empty(t, changed) + + root.Types[0].Attribute().DefaultValue = second + changed, err = snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Types[0].AttributeExpr.DefaultValue", changed) + + root.Types[0].Attribute().DefaultValue = (func(string) string)(nil) + changed, err = snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Types[0].AttributeExpr.DefaultValue", changed) +} + +// TestPreparedDesignSnapshotDetectsNilFunctionReplacement proves a function +// added where the prepared design stored nil is reported as a mutation. +func TestPreparedDesignSnapshotDetectsNilFunctionReplacement(t *testing.T) { + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: (func(string) string)(nil), + }, + }}} + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + + root.Types[0].Attribute().DefaultValue = func(string) string { return "added" } + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Types[0].AttributeExpr.DefaultValue", changed) +} + +// TestPreparedDesignSnapshotTreatsConversionExternalAsTypeToken proves that +// conversion exemplars contribute their exact Go type but not instance state. +func TestPreparedDesignSnapshotTreatsConversionExternalAsTypeToken(t *testing.T) { + type firstExternal struct { + channel chan int + values []string + } + type secondExternal struct{} + external := &firstExternal{channel: make(chan int), values: []string{"before"}} + typeMap := &expr.TypeMap{External: external} + root := &expr.RootExpr{Conversions: []*expr.TypeMap{typeMap}} + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + + external.values[0] = "after" + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Empty(t, changed) + + typeMap.External = &secondExternal{} + changed, err = snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Conversions[0].External", changed) +} + +// TestPreparedDesignSnapshotMapOrderIsStable proves randomized Go map +// iteration does not produce false mutation reports. +func TestPreparedDesignSnapshotMapOrderIsStable(t *testing.T) { + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: map[any]any{ + "zeta": []string{"last"}, + "alpha": []string{"first"}, + 42: "number", + }, + }, + }}} + + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + for range 100 { + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Empty(t, changed) + } +} + +// TestPreparedDesignSnapshotDetectsAliasReplacement proves replacing one of +// two aliases with an equal-value allocation changes the recorded topology. +func TestPreparedDesignSnapshotDetectsAliasReplacement(t *testing.T) { + service := &expr.ServiceExpr{Name: "service"} + root := &expr.RootExpr{Services: []*expr.ServiceExpr{service, service}} + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + + replacement := *service + root.Services[1] = &replacement + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Services[1]", changed) +} + +// TestPreparedDesignSnapshotDetectsInPlaceContainerMutation proves changes to +// existing map and slice storage are visible without replacing a container. +func TestPreparedDesignSnapshotDetectsInPlaceContainerMutation(t *testing.T) { + tests := []struct { + name string + mutate func(map[string][]string) + }{ + {"map", func(values map[string][]string) { values["second"] = []string{"new"} }}, + {"slice", func(values map[string][]string) { values["first"][0] = "changed" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + values := map[string][]string{"first": {"original"}} + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: values, + }, + }}} + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + + test.mutate(values) + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.NotEmpty(t, changed) + }) + } +} diff --git a/codegen/generator/example.go b/codegen/generator/example.go index 556911b6b1..bad67ff0a8 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -1,87 +1,135 @@ +// This file collects example service, server, and client files from the copied +// server data and the package names already chosen for this generation. package generator import ( + "fmt" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) -// Example iterates through the roots and returns files that implement an -// example service, server, and client. +// Example returns example service, server, and client files for roots. func Example(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - var files []*codegen.File - for _, root := range roots { - r, ok := root.(*expr.RootExpr) - if !ok { - continue // could be a plugin root expression - } - - // Create service data - services := service.NewServicesData(r) - for _, s := range r.Services { - service.SetUserTypeImports(genpkg, services.Get(s.Name)) - } + return runStandaloneGenerator(genpkg, roots, exampleGeneratorFactory) +} +// exampleFiles returns the service, server, and client examples selected for +// this generation. +func exampleFiles(plan *Plan) ([]*codegen.File, error) { + var files []*codegen.File + for _, entry := range plan.example { + services := entry.service.Services() // example service implementation - if fs := service.ExampleServiceFiles(genpkg, r, services); len(fs) != 0 { + if fs := service.ExampleServiceFiles(entry.service); len(fs) != 0 { files = append(files, fs...) } // example interceptors implementation - if fs := service.ExampleInterceptorsFiles(genpkg, r, services); len(fs) != 0 { + if fs := service.ExampleInterceptorsFiles(entry.service); len(fs) != 0 { files = append(files, fs...) } // server main - if fs := example.ServerFiles(genpkg, r, services); len(fs) != 0 { + if fs := example.ServerFiles(entry.root, services); len(fs) != 0 { files = append(files, fs...) } // CLI main - if fs := example.CLIFiles(genpkg, r); len(fs) != 0 { + if fs := example.CLIFiles(entry.root); len(fs) != 0 { files = append(files, fs...) } // HTTP - if len(r.API.HTTP.Services) > 0 { - httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) - if fs := httpcodegen.ExampleServerFiles(genpkg, httpServices); len(fs) != 0 { - files = append(files, fs...) + if entry.http != nil { + if entry.jsonrpc == nil { + if fs := entry.http.ServerFiles(); len(fs) != 0 { + files = append(files, fs...) + } } - if fs := httpcodegen.ExampleCLIFiles(genpkg, httpServices); len(fs) != 0 { + if fs := entry.http.CLIFiles(); len(fs) != 0 { files = append(files, fs...) } } // JSON-RPC - if len(r.API.JSONRPC.Services) > 0 { - jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &r.API.JSONRPC.HTTPExpr) - if fs := jsonrpccodegen.ExampleServerFiles(genpkg, jsonrpcServices, files); len(fs) > 0 { + if entry.jsonrpc != nil { + if fs := entry.jsonrpc.ServerFiles(); len(fs) > 0 { files = append(files, fs...) } - if fs := httpcodegen.ExampleCLIFiles(genpkg, jsonrpcServices); len(fs) > 0 { + if fs := entry.jsonrpc.CLIFiles(); len(fs) > 0 { files = append(files, fs...) } } // GRPC - if len(r.API.GRPC.Services) > 0 { - grpcServices := grpccodegen.NewServicesData(services) - if fs := grpccodegen.ExampleServerFiles(genpkg, grpcServices); len(fs) > 0 { + if entry.grpc != nil { + if fs := entry.grpc.ServerFiles(); len(fs) > 0 { files = append(files, fs...) } - if fs := grpccodegen.ExampleCLIFiles(genpkg, grpcServices); len(fs) > 0 { + if fs := entry.grpc.CLIFiles(); len(fs) > 0 { files = append(files, fs...) } } - - // Add imports defined via struct:field:type - addServicesMetaTypeImports(files, services, r.Services) } return files, nil } + +// planExampleData copies the server information used by example programs and +// prepares the selected transports. +func planExampleData(plan *Plan) error { + if err := planTransportData(plan); err != nil { + return err + } + roots := serviceRoots(plan.preparedRoots) + services := make([]*service.Plan, len(roots)) + for index, root := range roots { + services[index] = plan.Service(root) + } + examplePlan, err := example.NewPlan(plan.Generation(), services...) + if err != nil { + return err + } + plan.example = make([]*examplePlanEntry, len(roots)) + for index, root := range roots { + var httpExamples *httpcodegen.ExamplePlan + if transport := plan.http[root]; transport != nil { + httpExamples, err = httpcodegen.NewExamplePlan(transport, examplePlan) + if err != nil { + return err + } + } + var jsonrpcExamples *jsonrpccodegen.ExamplePlan + if transport := plan.jsonrpc[root]; transport != nil { + jsonrpcExamples, err = jsonrpccodegen.NewExamplePlan(transport, examplePlan) + if err != nil { + return err + } + } + var grpcExamples *grpccodegen.ExamplePlan + if transport := plan.grpc[root]; transport != nil { + grpcExamples, err = grpccodegen.NewExamplePlan(transport, examplePlan) + if err != nil { + return err + } + } + rootData, ok := examplePlan.Root(services[index]) + if !ok { + return fmt.Errorf("example plan does not contain server data for API %q", root.API.Name) + } + plan.example[index] = &examplePlanEntry{ + source: root, + root: rootData, + service: services[index], + http: httpExamples, + jsonrpc: jsonrpcExamples, + grpc: grpcExamples, + } + } + return nil +} diff --git a/codegen/generator/example_cli_input_stream_compile_test.go b/codegen/generator/example_cli_input_stream_compile_test.go new file mode 100644 index 0000000000..980fdd4cbf --- /dev/null +++ b/codegen/generator/example_cli_input_stream_compile_test.go @@ -0,0 +1,73 @@ +// This file compiles generated HTTP and gRPC example clients whose methods +// require streamed input. The example client must reject those commands +// without leaving unused endpoint values in the generated program. +package generator + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestExampleInputStreamClientsCompile generates client-streaming and +// bidirectional commands for HTTP and gRPC, then compiles the generated client. +func TestExampleInputStreamClientsCompile(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("gRPC input streams", func() { + dsl.Server("stream", func() { + dsl.Services("events") + dsl.Host("local", func() { + dsl.URI("http://localhost:8080") + dsl.URI("grpc://localhost:8080") + }) + }) + }) + dsl.Service("events", func() { + dsl.Method("upload", func() { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/upload") + }) + dsl.GRPC(func() {}) + }) + dsl.Method("exchange", func() { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.POST("/exchange") + }) + dsl.GRPC(func() {}) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + for _, file := range exampleFiles { + if strings.HasPrefix(file.Path, filepath.Join("cmd", "stream-cli")) { + files = append(files, file) + } + } + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/example_cli_result_runtime_test.go b/codegen/generator/example_cli_result_runtime_test.go new file mode 100644 index 0000000000..84ebb4663d --- /dev/null +++ b/codegen/generator/example_cli_result_runtime_test.go @@ -0,0 +1,238 @@ +// This file runs the result writers emitted into generated example clients. +// The test covers values received from a server stream and the errors returned +// when receiving or writing fails. +package generator + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestExampleCLIResultWritersRun verifies the generated helpers against real +// endpoint functions, stream receive functions, and writers. +func TestExampleCLIResultWritersRun(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("example stream", func() { + dsl.Server("stream", func() { + dsl.Services("events") + dsl.Host("local", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("events", func() { + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/events") + }) + }) + dsl.Method("upload", func() { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/events") + }) + }) + dsl.Method("create", func() { + dsl.Result(dsl.String) + dsl.StreamingResult(dsl.Int) + dsl.HTTP(func() { + dsl.POST("/create") + dsl.ServerSentEvents() + }) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + for _, file := range exampleFiles { + if strings.HasPrefix(file.Path, filepath.Join("cmd", "stream-cli")) { + files = append(files, file) + } + } + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + testPath := filepath.Join(directory, "cmd", "stream-cli", "result_writer_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(exampleCLIResultWriterTest), 0o600)) + runGeneratedTests(t, directory) +} + +const exampleCLIResultWriterTest = `package main + +import ( + "bytes" + "context" + "errors" + "flag" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + goa "goa.design/goa/v3/pkg" +) + +type failingWriter struct { + err error +} + +func (w failingWriter) Write([]byte) (int, error) { + return 0, w.err +} + +func TestWriteEndpointResult(t *testing.T) { + endpoint := goa.Endpoint(func(context.Context, any) (any, error) { + return map[string]string{"message": "hello"}, nil + }) + var output bytes.Buffer + if err := writeEndpointResult(context.Background(), &output, endpoint, nil); err != nil { + t.Fatal(err) + } + if got, want := output.String(), "{\n \"message\": \"hello\"\n}\n"; got != want { + t.Fatalf("unexpected output:\n%s", got) + } +} + +func TestWriteEndpointResultReturnsEncodingError(t *testing.T) { + endpoint := goa.Endpoint(func(context.Context, any) (any, error) { + return func() {}, nil + }) + err := writeEndpointResult(context.Background(), io.Discard, endpoint, nil) + if err == nil { + t.Fatal("expected JSON encoding error") + } +} + +func TestWriteStreamResults(t *testing.T) { + values := []string{"first", "second"} + next := 0 + recv := func(context.Context) (string, error) { + if next == len(values) { + return "", io.EOF + } + value := values[next] + next++ + return value, nil + } + var output bytes.Buffer + if err := writeStreamResults(context.Background(), &output, recv); err != nil { + t.Fatal(err) + } + if got, want := output.String(), "\"first\"\n\"second\"\n"; got != want { + t.Fatalf("unexpected output: %q", got) + } +} + +func TestWriteStreamResultsReturnsReceiveError(t *testing.T) { + want := errors.New("receive failed") + recv := func(context.Context) (string, error) { + return "", want + } + err := writeStreamResults(context.Background(), io.Discard, recv) + if !errors.Is(err, want) { + t.Fatalf("got %v, want wrapped receive error", err) + } +} + +func TestWriteStreamResultsDoesNotHideFailureJoinedWithEOF(t *testing.T) { + want := errors.New("close failed") + recv := func(context.Context) (string, error) { + return "", errors.Join(io.EOF, want) + } + err := writeStreamResults(context.Background(), io.Discard, recv) + if !errors.Is(err, want) { + t.Fatalf("got %v, want wrapped close error", err) + } +} + +func TestWriteStreamResultsReturnsOutputError(t *testing.T) { + want := errors.New("write failed") + called := false + recv := func(context.Context) (string, error) { + if called { + return "", io.EOF + } + called = true + return "value", nil + } + err := writeStreamResults(context.Background(), failingWriter{err: want}, recv) + if !errors.Is(err, want) { + t.Fatalf("got %v, want wrapped output error", err) + } +} + +func TestInputStreamIsRejectedBeforeCallingEndpoint(t *testing.T) { + args := os.Args + commandLine := flag.CommandLine + defer func() { + os.Args = args + flag.CommandLine = commandLine + }() + os.Args = []string{"stream-cli", "events", "upload"} + flag.CommandLine = flag.NewFlagSet("stream-cli", flag.ContinueOnError) + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + t.Fatal(err) + } + + err := doHTTP(context.Background(), "http", "127.0.0.1:1", 1, false, io.Discard) + want := "example client does not support streamed input for service \"events\" method \"upload\"" + if err == nil || err.Error() != want { + t.Fatalf("got %v, want %q", err, want) + } +} + +func TestMixedHTTPResultUsesNormalResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := io.WriteString(w, "\"created\""); err != nil { + t.Error(err) + } + })) + defer server.Close() + + args := os.Args + commandLine := flag.CommandLine + defer func() { + os.Args = args + flag.CommandLine = commandLine + }() + os.Args = []string{"stream-cli", "events", "create"} + flag.CommandLine = flag.NewFlagSet("stream-cli", flag.ContinueOnError) + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + err := doHTTP(context.Background(), "http", strings.TrimPrefix(server.URL, "http://"), 1, false, &output) + if err != nil { + t.Fatal(err) + } + if got, want := output.String(), "\"created\"\n"; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} +` diff --git a/codegen/generator/example_handler_args_integration_test.go b/codegen/generator/example_handler_args_integration_test.go new file mode 100644 index 0000000000..32d986e3a9 --- /dev/null +++ b/codegen/generator/example_handler_args_integration_test.go @@ -0,0 +1,48 @@ +// This file checks that generated starter servers pass transport arguments in +// the same order accepted by their generated helper functions. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestJSONRPCOnlyExampleServerCompiles checks a server whose two services are +// both exposed only through JSON-RPC. +func TestJSONRPCOnlyExampleServerCompiles(t *testing.T) { + root := codegen.RunDSL(t, func() { + for _, name := range []string{"First", "Second"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + } + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/example_immutability_test.go b/codegen/generator/example_immutability_test.go new file mode 100644 index 0000000000..92d8e1cff8 --- /dev/null +++ b/codegen/generator/example_immutability_test.go @@ -0,0 +1,52 @@ +// This file checks that example generation uses only values copied before Go +// names are finalized. +package generator + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example/testdata" + "goa.design/goa/v3/eval" +) + +// TestExampleFilesDoNotReadChangedServerDesign checks that changing the API or +// server after planning cannot change any example file. +func TestExampleFilesDoNotReadChangedServerDesign(t *testing.T) { + root := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + + beforeFiles, err := exampleFiles(plan) + require.NoError(t, err) + before := renderExampleFiles(t, beforeFiles) + + server := root.API.Servers[0] + root.API.Name = "changed api" + server.Name = "changed server" + server.Description = "changed description" + server.Services = nil + server.Hosts = nil + root.API.Servers = nil + + afterFiles, err := exampleFiles(plan) + require.NoError(t, err) + require.Equal(t, before, renderExampleFiles(t, afterFiles)) +} + +// renderExampleFiles writes each section and indexes the complete text by file +// path. +func renderExampleFiles(t *testing.T, files []*codegen.File) map[string]string { + t.Helper() + rendered := make(map[string]string, len(files)) + for _, file := range files { + var output bytes.Buffer + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&output)) + } + rendered[file.Path] = output.String() + } + return rendered +} diff --git a/codegen/generator/example_output_preservation_integration_test.go b/codegen/generator/example_output_preservation_integration_test.go new file mode 100644 index 0000000000..4ed3d98a39 --- /dev/null +++ b/codegen/generator/example_output_preservation_integration_test.go @@ -0,0 +1,109 @@ +// This file checks that starter files are preserved relative to the requested +// output directory, even when generation starts in a different directory. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +func TestExampleGenerationUsesOutputDirectoryForPreservation(t *testing.T) { + paths := []string{ + "status.go", + filepath.Join("interceptors", "status_server.go"), + filepath.Join("interceptors", "status_client.go"), + "multipart.go", + filepath.Join("cmd", "preserve", "main.go"), + filepath.Join("cmd", "preserve", "http.go"), + filepath.Join("cmd", "preserve", "grpc.go"), + filepath.Join("cmd", "preserve-cli", "main.go"), + filepath.Join("cmd", "preserve-cli", "http.go"), + filepath.Join("cmd", "preserve-cli", "grpc.go"), + } + preserved := map[string][]byte{ + "status.go": []byte("existing service\n"), + filepath.Join("interceptors", "status_server.go"): []byte("existing interceptor\n"), + "multipart.go": []byte("existing multipart helpers\n"), + filepath.Join("cmd", "preserve", "grpc.go"): []byte("existing server\n"), + filepath.Join("cmd", "preserve-cli", "http.go"): []byte("existing client\n"), + } + tests := []struct { + name string + existing map[string][]byte + }{ + {name: "output is empty"}, + {name: "output has starter files", existing: preserved}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + codegen.RunDSL(t, exampleOutputPreservationDSL) + launchDir := t.TempDir() + outputDir := t.TempDir() + writeGeneratedModule(t, filepath.Join(outputDir, codegen.Gendir), "generated.local/gen") + writeExampleFiles(t, launchDir, paths, []byte("file from launch directory\n")) + for path, content := range test.existing { + writeExampleFile(t, outputDir, path, content) + } + + t.Chdir(launchDir) + _, err := generate(outputDir, "example", false, newDefaultRegistry()) + require.NoError(t, err) + + for _, path := range paths { + content, err := os.ReadFile(filepath.Join(outputDir, path)) + require.NoError(t, err, path) + if existing, ok := test.existing[path]; ok { + require.Equal(t, existing, content, path) + } else { + require.NotEqual(t, []byte("file from launch directory\n"), content, path) + } + } + }) + } +} + +// exampleOutputPreservationDSL exercises every starter file producer that +// preserves user-written files. +func exampleOutputPreservationDSL() { + trace := d.Interceptor("trace") + d.API("preserve", func() {}) + d.Service("status", func() { + d.ServerInterceptor(trace) + d.ClientInterceptor(trace) + d.Method("upload", func() { + d.Payload(func() { + d.Field(1, "message", d.String) + }) + d.Result(d.String) + d.HTTP(func() { + d.POST("/upload") + d.MultipartRequest() + }) + d.GRPC(func() {}) + }) + }) +} + +// writeExampleFiles writes the same misleading content at every relative path +// in the directory where generation starts. +func writeExampleFiles(t *testing.T, dir string, paths []string, content []byte) { + t.Helper() + for _, path := range paths { + writeExampleFile(t, dir, path, content) + } +} + +// writeExampleFile writes one fixture file and creates its parent directory. +func writeExampleFile(t *testing.T, dir, path string, content []byte) { + t.Helper() + fullPath := filepath.Join(dir, path) + require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o750)) + require.NoError(t, os.WriteFile(fullPath, content, 0o600)) +} diff --git a/codegen/generator/example_plan_test.go b/codegen/generator/example_plan_test.go new file mode 100644 index 0000000000..61e066db76 --- /dev/null +++ b/codegen/generator/example_plan_test.go @@ -0,0 +1,79 @@ +// This file checks that plugins receive a separate copy of the example server +// description retained for the exact prepared design root. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/expr" +) + +func TestExampleReturnsSeparateCopyForExactRoot(t *testing.T) { + root := &expr.RootExpr{} + transport := &example.TransportData{ + Type: example.TransportHTTP, + Name: "HTTP", + Services: []string{"calc"}, + } + variable := &example.VariableData{ + Name: "version", + Description: "API version", + DefaultValue: "v1", + Values: []string{"v1", "v2"}, + } + planned := &example.Root{ + APIName: "calc", + Services: []string{"calc"}, + Servers: []*example.Data{{ + Name: "edge", + Description: "public server", + Services: []string{"calc"}, + Schemes: []string{"http"}, + Variables: []*example.VariableData{variable}, + Transports: []*example.TransportData{transport}, + Hosts: []*example.HostData{{ + Name: "development", + Schemes: []string{"http"}, + Variables: []*example.VariableData{variable}, + URIs: []*example.URIData{{ + URL: "http://localhost/{version}", + Scheme: "http", + Port: "80", + Transport: transport, + HandlerArgs: []example.HandlerArg{{ + Service: "calc", + Endpoint: true, + }}, + }}, + }}, + }}, + } + plan := &Plan{example: []*examplePlanEntry{{source: root, root: planned}}} + + got, ok := plan.Example(root) + require.True(t, ok) + require.Equal(t, planned, got) + require.NotSame(t, planned, got) + require.NotSame(t, planned.Servers[0], got.Servers[0]) + require.NotSame(t, planned.Servers[0].Hosts[0], got.Servers[0].Hosts[0]) + require.NotSame(t, variable, got.Servers[0].Variables[0]) + require.Same(t, got.Servers[0].Variables[0], got.Servers[0].Hosts[0].Variables[0]) + require.NotSame(t, transport, got.Servers[0].Transports[0]) + require.Same(t, got.Servers[0].Transports[0], got.Servers[0].Hosts[0].URIs[0].Transport) + + got.Services[0] = "changed" + got.Servers[0].Services[0] = "changed" + got.Servers[0].Variables[0].Values[0] = "changed" + got.Servers[0].Hosts[0].URIs[0].HandlerArgs[0].Service = "changed" + require.Equal(t, "calc", planned.Services[0]) + require.Equal(t, "calc", planned.Servers[0].Services[0]) + require.Equal(t, "v1", variable.Values[0]) + require.Equal(t, "calc", planned.Servers[0].Hosts[0].URIs[0].HandlerArgs[0].Service) + + got, ok = plan.Example(&expr.RootExpr{}) + require.False(t, ok) + require.Nil(t, got) +} diff --git a/codegen/generator/example_snapshot.go b/codegen/generator/example_snapshot.go new file mode 100644 index 0000000000..39a7678227 --- /dev/null +++ b/codegen/generator/example_snapshot.go @@ -0,0 +1,121 @@ +// This file copies the example server description exposed to plugins so a +// plugin cannot change the values retained by Goa for the current run. +package generator + +import "goa.design/goa/v3/codegen/example" + +// copyExampleRoot copies every exported slice and nested value that a plugin +// can read or change through the example plan API. +func copyExampleRoot(source *example.Root) *example.Root { + if source == nil { + return nil + } + copy := &example.Root{ + APIName: source.APIName, + Services: append([]string(nil), source.Services...), + Servers: make([]*example.Data, len(source.Servers)), + } + for index, server := range source.Servers { + copy.Servers[index] = copyExampleServer(server) + } + return copy +} + +// copyExampleServer preserves shared variable and transport pointers within +// one server while separating them from the retained plan. +func copyExampleServer(source *example.Data) *example.Data { + if source == nil { + return nil + } + copy := *source + copy.Services = append([]string(nil), source.Services...) + copy.Schemes = append([]string(nil), source.Schemes...) + variables := make(map[*example.VariableData]*example.VariableData, len(source.Variables)) + copy.Variables = copyExampleVariables(source.Variables, variables) + transports := make(map[*example.TransportData]*example.TransportData, len(source.Transports)) + copy.Transports = copyExampleTransports(source.Transports, transports) + copy.Hosts = make([]*example.HostData, len(source.Hosts)) + for index, host := range source.Hosts { + copy.Hosts[index] = copyExampleHost(host, variables, transports) + } + return © +} + +// copyExampleHost copies one host and reuses the copied server values referred +// to by its variables and URLs. +func copyExampleHost( + source *example.HostData, + variables map[*example.VariableData]*example.VariableData, + transports map[*example.TransportData]*example.TransportData, +) *example.HostData { + if source == nil { + return nil + } + copy := *source + copy.Schemes = append([]string(nil), source.Schemes...) + copy.Variables = copyExampleVariables(source.Variables, variables) + copy.URIs = make([]*example.URIData, len(source.URIs)) + for index, uri := range source.URIs { + if uri == nil { + continue + } + uriCopy := *uri + uriCopy.HandlerArgs = append([]example.HandlerArg(nil), uri.HandlerArgs...) + uriCopy.Transport = copyExampleTransport(uri.Transport, transports) + copy.URIs[index] = &uriCopy + } + return © +} + +// copyExampleVariables copies variables once so server and host lists still +// refer to the same copied value. +func copyExampleVariables( + sources []*example.VariableData, + copies map[*example.VariableData]*example.VariableData, +) []*example.VariableData { + result := make([]*example.VariableData, len(sources)) + for index, source := range sources { + if source == nil { + continue + } + copy := copies[source] + if copy == nil { + value := *source + value.Values = append([]string(nil), source.Values...) + copy = &value + copies[source] = copy + } + result[index] = copy + } + return result +} + +// copyExampleTransports copies transports once so server and URL descriptions +// still refer to the same copied value. +func copyExampleTransports( + sources []*example.TransportData, + copies map[*example.TransportData]*example.TransportData, +) []*example.TransportData { + result := make([]*example.TransportData, len(sources)) + for index, source := range sources { + result[index] = copyExampleTransport(source, copies) + } + return result +} + +// copyExampleTransport returns the copied form of one transport description. +func copyExampleTransport( + source *example.TransportData, + copies map[*example.TransportData]*example.TransportData, +) *example.TransportData { + if source == nil { + return nil + } + if copy := copies[source]; copy != nil { + return copy + } + copy := *source + copy.Services = append([]string(nil), source.Services...) + copies[source] = © + return © +} diff --git a/codegen/generator/example_state_test.go b/codegen/generator/example_state_test.go new file mode 100644 index 0000000000..a6e48e0c81 --- /dev/null +++ b/codegen/generator/example_state_test.go @@ -0,0 +1,139 @@ +// This file verifies that each generator execution owns independent mutable +// example state while sharing only immutable API factory configuration. +package generator + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // mutatingRandomizerFactory violates the immutable API factory contract so + // the lifecycle can prove it attributes the mutation at construction. + mutatingRandomizerFactory struct { + calls int + } +) + +// NewRandomizer records a call before returning a fresh deterministic stream. +func (f *mutatingRandomizerFactory) NewRandomizer(identity expr.ExampleIdentity) expr.Randomizer { + f.calls++ + return expr.NewDeterministicRandomizerFactory().NewRandomizer(identity) +} + +func TestGenerationRejectsFactoryMutationWhenStreamIsCreated(t *testing.T) { + root := expr.RunDSL(t, func() {}) + root.API.RandomizerFactory = &mutatingRandomizerFactory{} + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{name: "examples", Plan: func(plan *Plan) error { + plan.exampleGenerator(root).At(generatorTestIdentity()) + return nil + }} + }) + + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + + require.ErrorContains(t, err, `core "examples" plan mutated prepared design`) +} + +func TestGenerationRunsOwnIndependentExampleGenerators(t *testing.T) { + root := expr.RunDSL(t, func() {}) + factory := root.API.RandomizerFactory + registry := newRegistry() + var ( + mu sync.Mutex + generators []*expr.ExampleGenerator + examples []string + ) + registry.addCommand("test", func() coreGenerator { + return coreGenerator{name: "examples", Plan: func(plan *Plan) error { + generator := plan.exampleGenerator(root) + stream := generator.At(generatorTestIdentity()) + mu.Lock() + defer mu.Unlock() + generators = append(generators, generator) + examples = append(examples, stream.String()) + return nil + }} + }) + + for range 2 { + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + require.NoError(t, err) + } + + require.Len(t, generators, 2) + require.NotSame(t, generators[0], generators[1]) + require.Equal(t, examples[0], examples[1]) + require.Equal(t, factory, root.API.RandomizerFactory) +} + +// generatorTestIdentity returns a typed owner for values drawn directly by +// lifecycle tests rather than by a code-generation subsystem. +func generatorTestIdentity() expr.ExampleIdentity { + method := &expr.MethodExpr{ + Name: "lifecycle", + Service: &expr.ServiceExpr{Name: "generator-test"}, + } + return expr.MethodPayloadExampleIdentity(method) +} + +func TestConcurrentGenerationRunsOwnIndependentExampleGenerators(t *testing.T) { + registry := newRegistry() + roots := []*expr.RootExpr{expr.RunDSL(t, func() {}), expr.RunDSL(t, func() {})} + recursive := make(map[*expr.RootExpr]*expr.UserTypeExpr, len(roots)) + for _, root := range roots { + node := &expr.UserTypeExpr{TypeName: "Node", UID: "test-node"} + node.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "children", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: node}, + }}}, + }} + recursive[root] = node + } + var ( + mu sync.Mutex + generators []*expr.ExampleGenerator + examples []any + ) + registry.addCommand("test", func() coreGenerator { + return coreGenerator{name: "examples", Plan: func(plan *Plan) error { + root := plan.preparedRoots[0].(*expr.RootExpr) + generator := plan.exampleGenerator(root) + node := recursive[root] + example := node.Example(generator.At(expr.UserTypeExampleIdentity(node))) + mu.Lock() + defer mu.Unlock() + generators = append(generators, generator) + examples = append(examples, example) + return nil + }} + }) + + var runs sync.WaitGroup + errs := make(chan error, 2) + for _, root := range roots { + runs.Add(1) + go func(root *expr.RootExpr) { + defer runs.Done() + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + errs <- err + }(root) + } + runs.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + + require.Len(t, generators, 2) + require.NotSame(t, generators[0], generators[1]) + require.Equal(t, examples[0], examples[1]) +} diff --git a/codegen/generator/generate.go b/codegen/generator/generate.go index 1d34d30a5b..a2df02b250 100644 --- a/codegen/generator/generate.go +++ b/codegen/generator/generate.go @@ -1,26 +1,46 @@ +// The goa command calls this file with an output directory, command, and debug +// flag; it reads the evaluated design roots and returns the files it wrote. +// Every core generator and plugin plans against one Generation. Before any +// callback renders files, Goa chooses every package and declaration name and +// rejects attempts to add another declaration. package generator import ( + "errors" "fmt" + "io/fs" "os" + "os/exec" + "path" "path/filepath" "runtime" "sort" + "strings" "sync" "time" "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" + "golang.org/x/mod/module" "golang.org/x/tools/go/packages" ) // Generate runs the code generation algorithms. func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { + return generate(dir, cmd, debug, defaultRegistry) +} + +// generate runs code generation with an explicit registry so package tests can +// use isolated factories without replacing production globals. +func generate(dir, cmd string, debug bool, registry *registry) (outputs []string, err1 error) { startGenerate := time.Now() if debug { fmt.Fprintf(os.Stderr, "[TIMING] [generate] Starting generator.Generate()\n") } + run, err := newGenerationRun(cmd, registry) + if err != nil { + return nil, err + } // 1. Compute design roots. var roots []eval.Root @@ -68,144 +88,83 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { } startPkgLoad := time.Now() - pkgs, err := packages.Load(&packages.Config{Mode: packages.NeedName}, path) + genpkg, err = generatedPackageImportPath(path) if err != nil { return nil, err } - // In temporary workspaces (e.g., tests) and on Windows, PkgPath may resolve - // to an absolute filesystem path which is not a valid Go import path and - // would produce invalid imports (e.g., backslashes). Fall back to the - // relative generated package import path in that case. - if filepath.IsAbs(pkgs[0].PkgPath) { - genpkg = codegen.Gendir - } else { - genpkg = pkgs[0].PkgPath - } if debug { fmt.Fprintf(os.Stderr, "[TIMING] [generate] packages.Load took %v\n", time.Since(startPkgLoad)) fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 2: Compute gen package import path took %v\n", time.Since(start)) } } - // 3. Retrieve goa generators for given command. - var genfuncs []Genfunc - { - start := time.Now() - gs, err := Generators(cmd) - if err != nil { - return nil, err - } - genfuncs = gs - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 3: Retrieve goa generators took %v (%d generators)\n", time.Since(start), len(genfuncs)) - } + // 3. Prepare roots and build one plan. Choose every package and declaration + // name, then render core and plugin files through the fresh run objects + // created before root evaluation. + startLifecycle := time.Now() + result, err := run.execute(genpkg, roots) + if err != nil { + return nil, err } - - // 4. Run the code pre generation plugins then normalize the design - // roots. NormalizeRoot is the only sanctioned design mutation past eval - // finalization; it runs after the prepare plugins so plugin contributed - // endpoints are normalized too and before the generators so they all - // observe the same read-only design tree. - { - start := time.Now() - err := codegen.RunPluginsPrepare(cmd, genpkg, roots) - if err != nil { - return nil, err - } - for _, root := range roots { - if r, ok := root.(*expr.RootExpr); ok { - codegen.NormalizeRoot(r) - } - } - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 4: Run pre-generation plugins took %v\n", time.Since(start)) - } - } - - // 5. Generate initial set of files produced by goa code generators. - // NOTE: Parallelization causes infinite recursion in AsObject() for circular type references - var genfiles []*codegen.File - { - start := time.Now() - for i, gen := range genfuncs { - genStart := time.Now() - fs, err := gen(genpkg, roots) - if err != nil { - return nil, err - } - genfiles = append(genfiles, fs...) - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Generator %d produced %d files in %v\n", i, len(fs), time.Since(genStart)) - } - } - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 5: Generate initial files took %v (total %d files)\n", time.Since(start), len(genfiles)) - } + genfiles := result.files + if debug { + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 3: Lifecycle produced %d files in %v\n", len(genfiles), time.Since(startLifecycle)) } - // 6. Run the code generation plugins. + // 8. Merge files that target the same path to avoid overwriting content when + // multiple generators (or services) emit sections for the same file. { start := time.Now() - var err error - genfiles, err = codegen.RunPlugins(cmd, genpkg, roots, genfiles) + genfiles, err = mergeFilesByPath(genfiles) if err != nil { return nil, err } if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 6: Run post-generation plugins took %v (now %d files)\n", time.Since(start), len(genfiles)) - } - } - - // 7. Merge files that target the same path to avoid overwriting content when - // multiple generators (or services) emit sections for the same file. - { - start := time.Now() - genfiles = mergeFilesByPath(genfiles) - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 7: Merging files by path took %v (now %d files)\n", time.Since(start), len(genfiles)) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 8: Merging files by path took %v (now %d files)\n", time.Since(start), len(genfiles)) } } - // 8. Emit goa.json version file (gen command only). + // 9. Emit goa.json version file (gen command only). if cmd == "gen" { genfiles = append(genfiles, codegen.VersionFile()) } - // 9. Write the files (in parallel). + // 10. Write the files in parallel, then audit the prepared design after all + // templates and file finalizers have completed. written := make(map[string]struct{}) { start := time.Now() numWorkers := runtime.NumCPU() if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 9: Starting parallel file writing with %d workers\n", numWorkers) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 10: Starting parallel file writing with %d workers\n", numWorkers) } - // Channel for work items type workItem struct { index int file *codegen.File } workChan := make(chan workItem, len(genfiles)) - // Channel for results - type result struct { + type renderResult struct { index int filename string duration time.Duration err error } - resultChan := make(chan result, len(genfiles)) + resultChan := make(chan renderResult, len(genfiles)) - // Start worker pool - var wg sync.WaitGroup + var workers sync.WaitGroup for range numWorkers { - wg.Add(1) + workers.Add(1) go func() { - defer wg.Done() + defer workers.Done() for work := range workChan { renderStart := time.Now() filename, err := work.file.Render(dir) - resultChan <- result{ + if err != nil { + err = fmt.Errorf("render %s: %w", work.file.Path, err) + } + resultChan <- renderResult{ index: work.index, filename: filename, duration: time.Since(renderStart), @@ -215,45 +174,45 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { }() } - // Send all files to work channel - for i, f := range genfiles { - workChan <- workItem{index: i, file: f} + for i, file := range genfiles { + workChan <- workItem{index: i, file: file} } close(workChan) - // Wait for all workers to finish in a separate goroutine go func() { - wg.Wait() + workers.Wait() close(resultChan) }() - // Collect results + firstErrorIndex := len(genfiles) var firstErr error slowRenders := 0 - for res := range resultChan { - if res.err != nil && firstErr == nil { - firstErr = res.err + for render := range resultChan { + if render.err != nil && render.index < firstErrorIndex { + firstErrorIndex = render.index + firstErr = render.err } - if res.filename != "" { - written[res.filename] = struct{}{} + if render.filename != "" { + written[render.filename] = struct{}{} } - // Only log slow renders (>100ms) to avoid spam - if debug && res.duration > 100*time.Millisecond { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] File %d (%s) render took %v\n", res.index, res.filename, res.duration) + if debug && render.duration > 100*time.Millisecond { + fmt.Fprintf(os.Stderr, "[TIMING] [generate] File %d (%s) render took %v\n", render.index, render.filename, render.duration) slowRenders++ } } - + if err := result.plan.verifyPreparedDesign("generated file renders"); err != nil { + return nil, err + } if firstErr != nil { return nil, firstErr } if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 9: Write files took %v (%d files written, %d slow renders)\n", time.Since(start), len(written), slowRenders) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 10: Write files took %v (%d files written, %d slow renders)\n", time.Since(start), len(written), slowRenders) } } - // 10. Compute all output filenames. + // 11. Compute all output filenames. { start := time.Now() outputs = make([]string, len(written)) @@ -271,7 +230,7 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { i++ } if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 10: Compute output filenames took %v\n", time.Since(start)) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 11: Compute output filenames took %v\n", time.Since(start)) } } sort.Strings(outputs) @@ -282,63 +241,154 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { return outputs, nil } +// generatedPackageImportPath asks Go to identify the package in dir and +// returns its cleaned import path for generated files. +func generatedPackageImportPath(dir string) (string, error) { + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, + Dir: dir, + }, ".") + if err != nil { + return "", fmt.Errorf("load generated Go package in %q: %w", dir, err) + } + if len(pkgs) != 1 { + return "", fmt.Errorf("load generated Go package in %q: expected exactly one package, got %d", dir, len(pkgs)) + } + pkg := pkgs[0] + if len(pkg.Errors) != 0 { + packageErrors := make([]error, len(pkg.Errors)) + for i, packageError := range pkg.Errors { + packageErrors[i] = packageError + } + return "", fmt.Errorf("load generated Go package in %q: %w", dir, errors.Join(packageErrors...)) + } + importPath := pkg.PkgPath + if pkg.Module == nil && strings.HasPrefix(importPath, "_/") { + owned, err := gopathOwnsImportPath(pkg.Dir, importPath) + if err != nil { + return "", err + } + if !owned { + return "", fmt.Errorf("generated Go package in %q has synthetic import path %q", dir, importPath) + } + } + if path.Clean(importPath) != importPath { + return "", fmt.Errorf("generated Go package in %q has noncanonical import path %q", dir, importPath) + } + if err := module.CheckImportPath(importPath); err != nil { + return "", fmt.Errorf("generated Go package in %q has invalid import path %q: %w", dir, importPath, err) + } + return importPath, nil +} + +// gopathOwnsImportPath asks the Go command for its effective GOPATH and reports +// whether dir appears at importPath beneath one of GOPATH's source directories. +func gopathOwnsImportPath(dir, importPath string) (bool, error) { + output, err := exec.Command("go", "env", "GOPATH").Output() + if err != nil { + return false, fmt.Errorf("read effective GOPATH: %w", err) + } + gopath := string(output) + if strings.HasSuffix(gopath, "\r\n") { + gopath = strings.TrimSuffix(gopath, "\r\n") + } else { + gopath = strings.TrimSuffix(gopath, "\n") + } + roots := filepath.SplitList(gopath) + for _, root := range roots { + if gopathSourceOwnsImportPath(filepath.Join(root, "src"), dir, importPath) { + return true, nil + } + } + + resolvedDir, err := filepath.EvalSymlinks(dir) + if err != nil { + return false, fmt.Errorf("resolve generated Go package directory %q: %w", dir, err) + } + var resolutionErrors []error + for _, root := range roots { + packagePath := filepath.Join(root, "src", filepath.FromSlash(importPath)) + resolvedPackagePath, err := filepath.EvalSymlinks(packagePath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue + } + resolutionErrors = append(resolutionErrors, fmt.Errorf("resolve %q: %w", packagePath, err)) + continue + } + if resolvedPackagePath == resolvedDir { + return true, nil + } + } + if len(resolutionErrors) != 0 { + return false, fmt.Errorf("resolve GOPATH package path for %q: %w", importPath, errors.Join(resolutionErrors...)) + } + return false, nil +} + +// gopathSourceOwnsImportPath reports whether dir is lexically beneath source +// with the exact slash-separated relative import path. +func gopathSourceOwnsImportPath(source, dir, importPath string) bool { + relative, err := filepath.Rel(source, dir) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return false + } + return filepath.ToSlash(relative) == importPath +} + // mergeFilesByPath coalesces files that share the same output path by // concatenating their non-header sections and merging header imports. This // prevents later renders from truncating earlier content when multiple // services contribute sections to the same file (e.g., shared user types with // union value methods). -func mergeFilesByPath(files []*codegen.File) []*codegen.File { - if len(files) <= 1 { - return files +func mergeFilesByPath(files []*codegen.File) ([]*codegen.File, error) { + if len(files) == 0 { + return files, nil } byPath := make(map[string]*codegen.File) - namesByPath := make(map[string]map[string]struct{}) + portablePaths := make(map[string]string) - // First pass: build merged file per path + // First pass: build one complete file per path. for _, f := range files { if f == nil { continue } - path := f.Path - if existing, ok := byPath[path]; ok { - // Merge headers (index 0) imports - if len(existing.SectionTemplates) > 0 && len(f.SectionTemplates) > 0 { - mergeHeaderImports(existing.SectionTemplates[0], f.SectionTemplates[0]) + canonicalPath, portablePath, err := canonicalOutputFilePath(f.Path) + if err != nil { + return nil, err + } + if claimedPath, ok := portablePaths[portablePath]; ok && claimedPath != canonicalPath { + return nil, fmt.Errorf( + "generated file paths %q and %q collide on a case-insensitive filesystem", + claimedPath, + canonicalPath, + ) + } + portablePaths[portablePath] = canonicalPath + f.Path = canonicalPath + if existing, ok := byPath[canonicalPath]; ok { + if existing.SkipExist != f.SkipExist { + return nil, fmt.Errorf("generated file %q has conflicting SkipExist settings", canonicalPath) } - // Initialize seen section names for this path - if namesByPath[path] == nil { - namesByPath[path] = make(map[string]struct{}) - for _, st := range existing.SectionTemplates { - namesByPath[path][st.Name] = struct{}{} - } + existingHeader, existingHasHeader := firstHeader(existing) + contributorHeader, contributorHasHeader := firstHeader(f) + if existingHasHeader != contributorHasHeader { + return nil, fmt.Errorf("generated file %q mixes header and headerless contributions", canonicalPath) } - // Append unique sections (skip header at index 0) - for i, st := range f.SectionTemplates { - if i == 0 { - continue + sectionStart := 0 + if existingHasHeader { + if err := mergeHeaderImports(existingHeader, contributorHeader); err != nil { + return nil, fmt.Errorf("merge generated file %q: %w", canonicalPath, err) } - if _, seen := namesByPath[path][st.Name]; seen { - continue - } - existing.SectionTemplates = append(existing.SectionTemplates, st) - namesByPath[path][st.Name] = struct{}{} - } - // Preserve a finalize function if destination does not have one - if existing.FinalizeFunc == nil && f.FinalizeFunc != nil { - existing.FinalizeFunc = f.FinalizeFunc + sectionStart = 1 } - // Skip adding a duplicate File entry + existing.SectionTemplates = append(existing.SectionTemplates, f.SectionTemplates[sectionStart:]...) + existing.FinalizeFunc = composeFinalizers(existing.FinalizeFunc, f.FinalizeFunc) continue } - // New path: record and initialize seen names - byPath[path] = f - m := make(map[string]struct{}) - for _, st := range f.SectionTemplates { - m[st.Name] = struct{}{} - } - namesByPath[path] = m + byPath[canonicalPath] = f } // Second pass: preserve original order by first occurrence of each path @@ -356,43 +406,112 @@ func mergeFilesByPath(files []*codegen.File) []*codegen.File { seenPaths[f.Path] = struct{}{} } } - return merged + return merged, nil } // mergeHeaderImports merges the import specs from src header into dst header, -// deduplicating by (Name, Path). If either section is not a header produced by -// codegen.Header, this function is a no-op. -func mergeHeaderImports(dst, src *codegen.SectionTemplate) { - if dst == nil || src == nil { - return - } - dmap, dok := dst.Data.(map[string]any) - smap, sok := src.Data.(map[string]any) - if !dok || !sok { - return +// rejecting package and alias conflicts rather than producing invalid Go. +func mergeHeaderImports(dst, src *codegen.SectionTemplate) error { + dmap, _ := dst.Data.(map[string]any) + smap, _ := src.Data.(map[string]any) + dpkg, _ := dmap["Pkg"].(string) + spkg, _ := smap["Pkg"].(string) + if dpkg != spkg { + return fmt.Errorf("header packages %q and %q conflict", dpkg, spkg) } dlist, _ := dmap["Imports"].([]*codegen.ImportSpec) slist, _ := smap["Imports"].([]*codegen.ImportSpec) - if len(slist) == 0 { - return - } - seen := make(map[string]struct{}, len(dlist)) + paths := make(map[string]string, len(dlist)+len(slist)) + aliases := make(map[string]string, len(dlist)+len(slist)) for _, imp := range dlist { - if imp == nil { - continue + if _, err := recordImportSpec(paths, aliases, imp); err != nil { + return err } - seen[imp.Name+"|"+imp.Path] = struct{}{} } for _, imp := range slist { - if imp == nil { - continue + duplicate, err := recordImportSpec(paths, aliases, imp) + if err != nil { + return err } - key := imp.Name + "|" + imp.Path - if _, ok := seen[key]; ok { - continue + if !duplicate { + dlist = append(dlist, imp) } - dlist = append(dlist, imp) - seen[key] = struct{}{} } dmap["Imports"] = dlist + return nil +} + +// recordImportSpec validates one import against the complete merged header and +// reports whether the exact path and alias were already present. +func recordImportSpec(paths, names map[string]string, spec *codegen.ImportSpec) (bool, error) { + if spec == nil { + return true, nil + } + if alias, ok := paths[spec.Path]; ok { + if alias != spec.Name { + return false, fmt.Errorf("import path %q uses aliases %q and %q", spec.Path, alias, spec.Name) + } + return true, nil + } + localName := spec.Name + if localName != "" && localName != "_" && localName != "." { + if importPath, ok := names[localName]; ok { + return false, fmt.Errorf("import name %q refers to paths %q and %q", localName, importPath, spec.Path) + } + names[localName] = spec.Path + } + paths[spec.Path] = spec.Name + return false, nil +} + +// canonicalOutputFilePath cleans rawPath into the portable relative path used +// to group and render a generated file. The second result is case-folded so +// two paths cannot overwrite one another on a case-insensitive filesystem. +func canonicalOutputFilePath(rawPath string) (string, string, error) { + portable := filepath.ToSlash(rawPath) + portable = strings.ReplaceAll(portable, `\`, "/") + canonical := path.Clean(portable) + if canonical == "." || + canonical == ".." || + strings.HasPrefix(canonical, "../") || + strings.HasPrefix(canonical, "/") { + return "", "", fmt.Errorf("generated file path %q must stay within the output directory", rawPath) + } + if strings.Contains(canonical, ":") { + return "", "", fmt.Errorf("generated file path %q is not portable", rawPath) + } + return filepath.FromSlash(canonical), strings.ToLower(canonical), nil +} + +// firstHeader reports the header produced by codegen.Header when it is the +// first section of file. +func firstHeader(file *codegen.File) (*codegen.SectionTemplate, bool) { + if len(file.SectionTemplates) == 0 { + return nil, false + } + header := file.SectionTemplates[0] + data, ok := header.Data.(map[string]any) + if !ok { + return nil, false + } + _, hasPackage := data["Pkg"].(string) + _, hasImports := data["Imports"].([]*codegen.ImportSpec) + return header, hasPackage && hasImports +} + +// composeFinalizers preserves every same-path contributor's post-render work +// in contributor order and stops at the first error. +func composeFinalizers(first, second func(string) error) func(string) error { + if first == nil { + return second + } + if second == nil { + return first + } + return func(path string) error { + if err := first(path); err != nil { + return err + } + return second(path) + } } diff --git a/codegen/generator/generate_grpc_cli_collision_integration_test.go b/codegen/generator/generate_grpc_cli_collision_integration_test.go new file mode 100644 index 0000000000..ba672dadf3 --- /dev/null +++ b/codegen/generator/generate_grpc_cli_collision_integration_test.go @@ -0,0 +1,68 @@ +// This file verifies that a generated gRPC command parser calls the exact +// client constructor selected for its generated client package. +package generator + +import ( + "path" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +type grpcClientCollisionOrder string + +// ComparePackageName orders the declaration added by this collision test. +func (o grpcClientCollisionOrder) ComparePackageName(other codegen.PackageNameOrder) int { + return strings.Compare(string(o), string(other.(grpcClientCollisionOrder))) +} + +func TestGeneratedGRPCCLIUsesFinalClientConstructorName(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Records", func() { + dsl.Method("Read", func() { + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + }) + }) + reserve := func(plan *Plan) error { + clientPackage, err := plan.Generation().ClaimPackage(path.Join( + "generated.local/gen", "grpc", "records", "client", + )) + if err != nil { + return err + } + return clientPackage.DeclareName(codegen.NewPreferredName( + codegen.NameFunction, + "NewClient", + codegen.ExportedName, + grpcClientCollisionOrder("plugin-client-constructor"), + )) + } + plan := mustTestPlan( + t, + "generated.local/gen", + []eval.Root{root}, + planServiceData, + reserve, + planTransportData, + ) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/generate_grpc_metadata_integration_test.go b/codegen/generator/generate_grpc_metadata_integration_test.go new file mode 100644 index 0000000000..a6ca6b3e96 --- /dev/null +++ b/codegen/generator/generate_grpc_metadata_integration_test.go @@ -0,0 +1,159 @@ +// This file verifies that generated gRPC metadata codecs convert between +// native header values and relocated service aliases in both directions. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +func TestGenerateGRPCMetadataAliasesCompile(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + + _ = codegen.RunDSL(t, func() { + d.API("metadata", func() {}) + value := d.Type("Value", d.Int, func() { + d.Enum(1, 2) + d.Meta("struct:pkg:path", "shared/types") + }) + values := d.Type("Values", d.ArrayOf(value), func() { + d.Meta("struct:pkg:path", "shared/types") + }) + payload := d.Type("Payload", func() { + d.Field(1, "required_values", values) + d.Field(2, "optional_value", value) + d.Field(3, "anonymous_values", d.ArrayOf(value)) + d.Field(4, "optional_values", values) + d.Required("required_values", "anonymous_values") + }) + result := d.Type("Result", func() { + d.Field(1, "header_values", values) + d.Field(2, "trailer_value", value) + d.Field(3, "optional_header_values", values) + d.Required("header_values") + }) + d.Service("Metadata", func() { + d.Method("Exchange", func() { + d.Payload(payload) + d.Result(result) + d.GRPC(func() { + d.Metadata(func() { + d.Attribute("required_values") + d.Attribute("optional_value") + d.Attribute("anonymous_values") + d.Attribute("optional_values") + }) + d.Response(func() { + d.Headers(func() { + d.Attribute("header_values") + d.Attribute("optional_header_values") + }) + d.Trailers(func() { d.Attribute("trailer_value") }) + }) + }) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + if _, err := generate(dir, "gen", false, registry); err != nil { + t.Fatalf("generate gRPC metadata module: %v", err) + } + writeGRPCMetadataRoundTripTest(t, genDir) + runGeneratedTests(t, genDir) +} + +// writeGRPCMetadataRoundTripTest adds consumer code outside the generated +// packages that exercises both metadata directions through their public API. +func writeGRPCMetadataRoundTripTest(t *testing.T, moduleDir string) { + t.Helper() + dir := filepath.Join(moduleDir, "roundtrip") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create metadata round-trip package: %v", err) + } + const source = `package roundtrip_test + +import ( + "context" + "testing" + + genclient "generated.local/gen/grpc/metadata/client" + genserver "generated.local/gen/grpc/metadata/server" + genmetadata "generated.local/gen/metadata" + gentypes "generated.local/gen/shared/types" + "google.golang.org/grpc/metadata" +) + +func TestMetadataRoundTrip(t *testing.T) { + ctx := context.Background() + optional := gentypes.Value(2) + payload := &genmetadata.Payload{ + RequiredValues: gentypes.Values{1, 2}, + OptionalValue: &optional, + AnonymousValues: []gentypes.Value{2, 1}, + OptionalValues: gentypes.Values{1}, + } + requestMetadata := metadata.MD{} + message, err := genclient.EncodeExchangeRequest(ctx, payload, &requestMetadata) + if err != nil { + t.Fatal(err) + } + decoded, err := genserver.DecodeExchangeRequest(ctx, message, requestMetadata) + if err != nil { + t.Fatal(err) + } + gotPayload := decoded.(*genmetadata.Payload) + if len(gotPayload.RequiredValues) != 2 || gotPayload.RequiredValues[1] != 2 || *gotPayload.OptionalValue != 2 || len(gotPayload.AnonymousValues) != 2 || gotPayload.AnonymousValues[1] != 1 || len(gotPayload.OptionalValues) != 1 || gotPayload.OptionalValues[0] != 1 { + t.Fatalf("unexpected payload: %#v", gotPayload) + } + for _, optionalValues := range []gentypes.Values{nil, {}} { + payload.OptionalValues = optionalValues + requestMetadata = metadata.MD{} + message, err = genclient.EncodeExchangeRequest(ctx, payload, &requestMetadata) + if err != nil { + t.Fatal(err) + } + decoded, err = genserver.DecodeExchangeRequest(ctx, message, requestMetadata) + if err != nil { + t.Fatal(err) + } + if got := decoded.(*genmetadata.Payload); len(got.OptionalValues) != 0 { + t.Fatalf("unexpected absent optional values: %#v", got.OptionalValues) + } + } + + trailer := gentypes.Value(1) + result := &genmetadata.Result{ + HeaderValues: gentypes.Values{2, 1}, + TrailerValue: &trailer, + OptionalHeaderValues: gentypes.Values{1}, + } + headers, trailers := metadata.MD{}, metadata.MD{} + response, err := genserver.EncodeExchangeResponse(ctx, result, &headers, &trailers) + if err != nil { + t.Fatal(err) + } + decoded, err = genclient.DecodeExchangeResponse(ctx, response, headers, trailers) + if err != nil { + t.Fatal(err) + } + gotResult := decoded.(*genmetadata.Result) + if len(gotResult.HeaderValues) != 2 || gotResult.HeaderValues[0] != 2 || *gotResult.TrailerValue != 1 || len(gotResult.OptionalHeaderValues) != 1 || gotResult.OptionalHeaderValues[0] != 1 { + t.Fatalf("unexpected result: %#v", gotResult) + } +} +` + if err := os.WriteFile(filepath.Join(dir, "roundtrip_test.go"), []byte(source), 0o600); err != nil { + t.Fatalf("write metadata round-trip test: %v", err) + } +} diff --git a/codegen/generator/generate_grpc_required_array_alias_integration_test.go b/codegen/generator/generate_grpc_required_array_alias_integration_test.go new file mode 100644 index 0000000000..42f155d33d --- /dev/null +++ b/codegen/generator/generate_grpc_required_array_alias_integration_test.go @@ -0,0 +1,42 @@ +// This file verifies that generated gRPC codecs compile for required arrays +// whose service elements are primitive aliases. +package generator + +import ( + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +// TestGenerateGRPCRequiredPrimitiveAliasArray proves the service-to-protobuf +// and protobuf-to-service conversions preserve required string alias elements. +func TestGenerateGRPCRequiredPrimitiveAliasArray(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + + _ = codegen.RunDSL(t, func() { + alias := d.Type("Alias", d.String) + payload := d.Type("Payload", func() { + d.Field(1, "values", d.ArrayOfRequired(alias)) + d.Required("values") + }) + d.Service("Aliases", func() { + d.Method("Store", func() { + d.Payload(payload) + d.GRPC(func() {}) + }) + }) + }) + + directory := filepath.Join(t.TempDir(), codegen.Gendir) + writeGeneratedModule(t, directory, "generated.local/gen") + if _, err := generate(filepath.Dir(directory), "gen", false, registry); err != nil { + t.Fatalf("generate required primitive alias array: %v", err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/generate_grpc_required_union_validation_integration_test.go b/codegen/generator/generate_grpc_required_union_validation_integration_test.go new file mode 100644 index 0000000000..d258db396c --- /dev/null +++ b/codegen/generator/generate_grpc_required_union_validation_integration_test.go @@ -0,0 +1,184 @@ +// This file checks that generated gRPC clients and servers reject an empty +// required OneOf and reject a selected branch whose value is nil. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +func TestGenerateGRPCRequiredUnionValidators(t *testing.T) { + root := codegen.RunDSL(t, requiredGRPCUnionValidationDSL) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + writeGRPCRequiredUnionValidationTest(t, dir) + runGeneratedTests(t, dir) +} + +// requiredGRPCUnionValidationDSL creates request and response unions with the +// same branches so both generated checks must enforce the same rules. +func requiredGRPCUnionValidationDSL() { + d.API("required-union", func() {}) + token := d.Type("Token", d.String) + detail := d.Type("Detail", func() { + d.Field(1, "label", d.String) + d.Required("label") + }) + inactive := d.Type("Inactive", func() {}) + request := d.Type("RequestChoice", func() { + d.OneOf("choice", func() { + d.Field(1, "number", d.Int, func() { d.Minimum(1) }) + d.Field(2, "detail", detail) + d.Field(3, "inactive", inactive) + d.Field(4, "blob", d.Bytes) + d.Field(5, "token", token) + }) + d.Required("choice") + }) + response := d.Type("ResponseChoice", func() { + d.OneOf("choice", func() { + d.Field(1, "number", d.Int, func() { d.Minimum(1) }) + d.Field(2, "detail", detail) + d.Field(3, "inactive", inactive) + d.Field(4, "blob", d.Bytes) + d.Field(5, "token", token) + }) + d.Required("choice") + }) + d.Service("validation", func() { + d.Method("Exchange", func() { + d.Payload(request) + d.Result(response) + d.GRPC(func() {}) + }) + }) +} + +// writeGRPCRequiredUnionValidationTest adds a test which calls the generated +// server and client validation functions. +func writeGRPCRequiredUnionValidationTest(t *testing.T, moduleDir string) { + t.Helper() + dir := filepath.Join(moduleDir, "uniontest") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create required union validation package: %v", err) + } + const source = `package uniontest_test + +import ( + "errors" + "testing" + + goa "goa.design/goa/v3/pkg" + genclient "generated.local/gen/grpc/validation/client" + genpb "generated.local/gen/grpc/validation/pb" + genserver "generated.local/gen/grpc/validation/server" +) + +func TestServerRequestValidator(t *testing.T) { + valid := []*genpb.ExchangeRequest{ + {Choice: &genpb.ExchangeRequest_Number{Number: 1}}, + {Choice: &genpb.ExchangeRequest_Detail{Detail: &genpb.Detail{Label: "ready"}}}, + {Choice: &genpb.ExchangeRequest_Inactive{Inactive: &genpb.Inactive{}}}, + {Choice: &genpb.ExchangeRequest_Blob{Blob: []byte{}}}, + {Choice: &genpb.ExchangeRequest_Token{Token: "ready"}}, + } + for _, message := range valid { + if err := genserver.ValidateExchangeRequest(message); err != nil { + t.Errorf("valid request branch failed: %v", err) + } + } + + var nilNumber *genpb.ExchangeRequest_Number + assertErrorName(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Number{Number: 0}}), goa.InvalidRange) + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{}), "choice", "\"choice\" is missing from message") + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: nilNumber}), "number", "\"number\" is missing from message.choice") + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Detail{}}), "detail", "\"detail\" is missing from message.choice") + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Inactive{}}), "inactive", "\"inactive\" is missing from message.choice") + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Blob{}}), "blob", "\"blob\" is missing from message.choice") +} + +func TestClientResponseValidator(t *testing.T) { + valid := []*genpb.ExchangeResponse{ + {Choice: &genpb.ExchangeResponse_Number{Number: 1}}, + {Choice: &genpb.ExchangeResponse_Detail{Detail: &genpb.Detail{Label: "ready"}}}, + {Choice: &genpb.ExchangeResponse_Inactive{Inactive: &genpb.Inactive{}}}, + {Choice: &genpb.ExchangeResponse_Blob{Blob: []byte{}}}, + {Choice: &genpb.ExchangeResponse_Token{Token: "ready"}}, + } + for _, message := range valid { + if err := genclient.ValidateExchangeResponse(message); err != nil { + t.Errorf("valid response branch failed: %v", err) + } + } + + var nilDetail *genpb.ExchangeResponse_Detail + assertErrorName(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Number{Number: 0}}), goa.InvalidRange) + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{}), "choice", "\"choice\" is missing from message") + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: nilDetail}), "detail", "\"detail\" is missing from message.choice") + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Detail{}}), "detail", "\"detail\" is missing from message.choice") + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Inactive{}}), "inactive", "\"inactive\" is missing from message.choice") + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Blob{}}), "blob", "\"blob\" is missing from message.choice") +} + +// assertErrorName checks that generated validation returned the expected Goa error name. +func assertErrorName(t *testing.T, err error, name string) { + t.Helper() + if err == nil { + t.Errorf("expected %q error", name) + return + } + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Errorf("expected Goa service error, got %T: %v", err, err) + return + } + if serviceError.Name != name { + t.Errorf("expected %q, got %q", name, serviceError.Name) + } +} + +// assertMissingField checks the error name, field, and message returned for a missing protobuf value. +func assertMissingField(t *testing.T, err error, field, message string) { + t.Helper() + if err == nil { + t.Errorf("expected missing field %q", field) + return + } + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Errorf("expected Goa service error, got %T: %v", err, err) + return + } + if serviceError.Name != goa.MissingField { + t.Errorf("expected %q, got %q", goa.MissingField, serviceError.Name) + } + if serviceError.Field == nil || *serviceError.Field != field { + t.Errorf("expected field %q, got %#v", field, serviceError.Field) + } + if serviceError.Message != message { + t.Errorf("expected message %q, got %q", message, serviceError.Message) + } +} +` + if err := os.WriteFile(filepath.Join(dir, "required_union_validation_test.go"), []byte(source), 0o600); err != nil { + t.Fatalf("write required union validation test: %v", err) + } +} diff --git a/codegen/generator/generate_http_error_result_integration_test.go b/codegen/generator/generate_http_error_result_integration_test.go new file mode 100644 index 0000000000..3ad194c2a8 --- /dev/null +++ b/codegen/generator/generate_http_error_result_integration_test.go @@ -0,0 +1,45 @@ +// This file verifies that HTTP clients keep Goa's built-in service error type +// after the transport generator copies method error expressions. +package generator + +import ( + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +func TestGenerateHTTPErrorResultAndCustomError(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + codegen.RunDSL(t, func() { + custom := dsl.Type("CustomError", func() { + dsl.ErrorName("name", dsl.String) + dsl.Attribute("message", dsl.String) + dsl.Required("name", "message") + }) + dsl.Service("Records", func() { + dsl.Method("Read", func() { + dsl.Error("not_found") + dsl.Error("rejected", custom) + dsl.HTTP(func() { + dsl.GET("/records") + dsl.Response("not_found", dsl.StatusNotFound) + dsl.Response("rejected", dsl.StatusBadRequest) + }) + }) + }) + }) + + directory := filepath.Join(t.TempDir(), codegen.Gendir) + writeGeneratedModule(t, directory, "generated.local/gen") + _, err := generate(filepath.Dir(directory), "gen", false, registry) + if err != nil { + t.Fatalf("generate HTTP errors: %v", err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/generate_http_multipart_validation_integration_test.go b/codegen/generator/generate_http_multipart_validation_integration_test.go new file mode 100644 index 0000000000..77868f32d4 --- /dev/null +++ b/codegen/generator/generate_http_multipart_validation_integration_test.go @@ -0,0 +1,205 @@ +// This file checks complete multipart generation, including the starter +// decoder signatures and the validation that runs before payload construction. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestGenerateHTTPMultipartValidationCompilesAndRuns verifies generated +// object, array, and map bodies together with the generated starter decoder. +func TestGenerateHTTPMultipartValidationCompilesAndRuns(t *testing.T) { + root := codegen.RunDSL(t, multipartValidationDSL) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + writeMultipartValidationRuntimeTest(t, directory) + runGeneratedTests(t, directory) +} + +// multipartValidationDSL defines one object body with mapped request values +// and two composite bodies that exercise generated callback signatures. +func multipartValidationDSL() { + part := d.Type("Part", func() { + d.Attribute("code", d.String) + d.Required("code") + }) + objectPayload := d.Type("ObjectPayload", func() { + d.Attribute("name", d.String) + d.Attribute("part", part) + d.Attribute("site", d.String) + d.Attribute("count", d.Int) + d.Attribute("token", d.String) + d.Required("name", "part", "site", "count") + }) + d.Service("upload", func() { + d.Method("Object", func() { + d.Payload(objectPayload) + d.HTTP(func() { + d.POST("/objects/{site}") + d.Param("count") + d.Header("token:X-Token") + d.MultipartRequest() + }) + }) + d.Method("Array", func() { + d.Payload(d.ArrayOf(part)) + d.HTTP(func() { + d.POST("/array") + d.MultipartRequest() + }) + }) + d.Method("Map", func() { + d.Payload(d.MapOf(d.String, d.Int)) + d.HTTP(func() { + d.POST("/map") + d.MultipartRequest() + }) + }) + }) +} + +// writeMultipartValidationRuntimeTest adds assertions against the generated +// request decoder so validation order and payload construction are exercised. +func writeMultipartValidationRuntimeTest(t *testing.T, directory string) { + t.Helper() + const source = `package multiparttest_test + +import ( + "errors" + "net/http" + "testing" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" + genserver "generated.local/gen/http/upload/server" +) + +type mux struct{} + +func (mux) Handle(string, string, http.HandlerFunc) {} +func (mux) ServeHTTP(http.ResponseWriter, *http.Request) {} +func (mux) Vars(*http.Request) map[string]string { return map[string]string{"site": "west"} } + +func TestObjectValidationRunsBeforeConstruction(t *testing.T) { + code := "ready" + request, err := http.NewRequest(http.MethodPost, "/objects/west?count=2", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("X-Token", "secret") + + missingName := genserver.DecodeObjectRequest(mux{}, bodyDecoder(func(body *genserver.ObjectRequestBody) { + body.Part = &genserver.PartRequestBody{Code: &code} + })) + _, err = missingName(request) + assertMissingField(t, err, "name", "body") + + name := "report" + missingCode := genserver.DecodeObjectRequest(mux{}, bodyDecoder(func(body *genserver.ObjectRequestBody) { + body.Name = &name + body.Part = &genserver.PartRequestBody{} + })) + _, err = missingCode(request) + assertMissingField(t, err, "code", "body.part") + + valid := genserver.DecodeObjectRequest(mux{}, bodyDecoder(func(body *genserver.ObjectRequestBody) { + body.Name = &name + body.Part = &genserver.PartRequestBody{Code: &code} + })) + payload, err := valid(request) + if err != nil { + t.Fatalf("valid multipart body failed: %v", err) + } + if payload.Name != name || payload.Part.Code != code || payload.Site != "west" || payload.Count != 2 { + t.Fatalf("unexpected payload: %#v", payload) + } + if payload.Token == nil || *payload.Token != "secret" { + t.Fatalf("mapped header was not preserved: %#v", payload.Token) + } +} + +func TestArrayAndMapBodiesConstructPayloads(t *testing.T) { + code := "ready" + request, err := http.NewRequest(http.MethodPost, "/", nil) + if err != nil { + t.Fatal(err) + } + decodeArray := genserver.DecodeArrayRequest(mux{}, func(*http.Request) goahttp.Decoder { + return goahttp.EncodingFunc(func(value any) error { + body := value.(*[]*genserver.PartRequestBody) + *body = []*genserver.PartRequestBody{{Code: &code}} + return nil + }) + }) + array, err := decodeArray(request) + if err != nil || len(array) != 1 || array[0].Code != code { + t.Fatalf("unexpected array payload: %#v, %v", array, err) + } + + decodeMap := genserver.DecodeMapRequest(mux{}, func(*http.Request) goahttp.Decoder { + return goahttp.EncodingFunc(func(value any) error { + body := value.(*map[string]int) + *body = map[string]int{"count": 2} + return nil + }) + }) + values, err := decodeMap(request) + if err != nil || values["count"] != 2 { + t.Fatalf("unexpected map payload: %#v, %v", values, err) + } +} + +func bodyDecoder(fill func(*genserver.ObjectRequestBody)) func(*http.Request) goahttp.Decoder { + return func(*http.Request) goahttp.Decoder { + return goahttp.EncodingFunc(func(value any) error { + fill(value.(*genserver.ObjectRequestBody)) + return nil + }) + } +} + +func assertMissingField(t *testing.T, err error, field, location string) { + t.Helper() + if err == nil { + t.Fatalf("expected missing field %q", field) + } + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Fatalf("expected Goa service error, got %T: %v", err, err) + } + if serviceError.Name != goa.MissingField || serviceError.Field == nil || *serviceError.Field != field { + t.Fatalf("unexpected missing field error: %#v", serviceError) + } + if serviceError.Message != "\""+field+"\" is missing from "+location { + t.Fatalf("unexpected message: %q", serviceError.Message) + } +} +` + dir := filepath.Join(directory, "multiparttest") + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "multipart_validation_test.go"), []byte(source), 0o600)) +} diff --git a/codegen/generator/generate_http_required_array_alias_integration_test.go b/codegen/generator/generate_http_required_array_alias_integration_test.go new file mode 100644 index 0000000000..f07c4afc0e --- /dev/null +++ b/codegen/generator/generate_http_required_array_alias_integration_test.go @@ -0,0 +1,120 @@ +// This file verifies that HTTP validation keeps null array elements visible +// without changing primitive alias elements in service values. +package generator + +import ( + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestGenerateHTTPRequiredPrimitiveAliasArray checks the generated service and +// HTTP packages for an array whose string alias elements cannot be null. +func TestGenerateHTTPRequiredPrimitiveAliasArray(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + codegen.RunDSL(t, func() { + alias := dsl.Type("Alias", dsl.String, func() { + dsl.Pattern("^[a-z]*$") + }) + nested := dsl.Type("Nested", func() { + dsl.Field(1, "values", dsl.ArrayOfRequired(alias)) + dsl.Required("values") + }) + payload := dsl.Type("StorePayload", func() { + dsl.Field(1, "names", dsl.ArrayOfRequired(dsl.String)) + dsl.Field(2, "values", dsl.ArrayOfRequired(alias)) + dsl.Field(3, "nested", nested) + dsl.Required("names", "values", "nested") + }) + searchPayload := dsl.Type("SearchPayload", func() { + dsl.Attribute("values", dsl.ArrayOfRequired(alias)) + dsl.Required("values") + }) + dsl.Service("Aliases", func() { + dsl.Method("Store", func() { + dsl.Payload(payload) + dsl.HTTP(func() { + dsl.POST("/aliases") + }) + dsl.GRPC(func() {}) + }) + dsl.Method("Search", func() { + dsl.Payload(searchPayload) + dsl.HTTP(func() { + dsl.GET("/aliases") + dsl.Param("values") + }) + }) + }) + }) + + directory := filepath.Join(t.TempDir(), codegen.Gendir) + writeGeneratedModule(t, directory, "generated.local/gen") + _, err := generate(filepath.Dir(directory), "gen", false, registry) + if err != nil { + t.Fatalf("generate required primitive alias array: %v", err) + } + writeGeneratedContractTest( + t, + directory, + filepath.Join("http", "aliases", "server"), + requiredPrimitiveAliasArrayRuntimeTest, + ) + runGeneratedTests(t, directory) +} + +const requiredPrimitiveAliasArrayRuntimeTest = `package server + +import ( + "encoding/json" + "testing" + + aliases "generated.local/gen/aliases" + goa "goa.design/goa/v3/pkg" +) + +func TestRequiredPrimitiveArrayElements(t *testing.T) { + var valid StoreRequestBody + if err := json.Unmarshal([]byte("{\"names\":[\"\"],\"values\":[\"\"],\"nested\":{\"values\":[\"\"]}}"), &valid); err != nil { + t.Fatalf("decode valid body: %v", err) + } + if err := ValidateStoreRequestBody(&valid); err != nil { + t.Fatalf("validate empty strings: %v", err) + } + payload := NewStorePayload(&valid) + if len(payload.Names) != 1 || payload.Names[0] != "" { + t.Fatalf("converted names = %#v", payload.Names) + } + if len(payload.Values) != 1 || payload.Values[0] != aliases.Alias("") { + t.Fatalf("converted aliases = %#v", payload.Values) + } + if payload.Nested == nil || len(payload.Nested.Values) != 1 || payload.Nested.Values[0] != aliases.Alias("") { + t.Fatalf("converted nested aliases = %#v", payload.Nested) + } + + assertNullElement := func(body string, context string) { + t.Helper() + var decoded StoreRequestBody + if err := json.Unmarshal([]byte(body), &decoded); err != nil { + t.Fatalf("decode null element: %v", err) + } + err := ValidateStoreRequestBody(&decoded) + if err == nil { + t.Fatal("null element passed validation") + } + want := goa.MissingFieldError(context, "[*]").Error() + if err.Error() != want { + t.Fatalf("validation error = %q, want %q", err, want) + } + } + assertNullElement("{\"names\":[null],\"values\":[\"ok\"],\"nested\":{\"values\":[\"ok\"]}}", "body.names") + assertNullElement("{\"names\":[\"ok\"],\"values\":[null],\"nested\":{\"values\":[\"ok\"]}}", "body.values") + assertNullElement("{\"names\":[\"ok\"],\"values\":[\"ok\"],\"nested\":{\"values\":[null]}}", "body.nested.values") +} +` diff --git a/codegen/generator/generate_http_union_shape_integration_test.go b/codegen/generator/generate_http_union_shape_integration_test.go new file mode 100644 index 0000000000..cc2b252672 --- /dev/null +++ b/codegen/generator/generate_http_union_shape_integration_test.go @@ -0,0 +1,150 @@ +// This file verifies that HTTP generation keeps request- and response-shaped +// unions separate when their nested branch types have different Go names. +package generator + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +func TestGenerateHTTPUnionUsedByRequestAndResponseCompiles(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + + dsl := func() { + d.API("test", func() {}) + + siteSet := d.Type("SiteSet", func() { + d.Attribute("site_ids", d.ArrayOf(d.String)) + d.Required("site_ids") + }) + allSites := d.Type("AllSites", func() { + d.Attribute("include_current", d.Boolean) + d.Required("include_current") + }) + setup := d.Type("Setup", func() { + d.OneOf("scope", func() { + d.Attribute("site_set", siteSet) + d.Attribute("all_sites", allSites) + }) + d.Required("scope") + }) + + d.Service("front", func() { + d.Method("configure", func() { + d.Payload(setup) + d.Result(setup) + d.HTTP(func() { + d.POST("/configure") + d.Response(200) + }) + }) + d.Method("reconfigure", func() { + d.Payload(setup) + d.Result(d.String) + d.HTTP(func() { + d.POST("/reconfigure") + d.Response(200) + }) + }) + }) + } + + _ = codegen.RunDSL(t, dsl) + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + if _, err := generate(dir, "gen", false, registry); err != nil { + t.Fatalf("Generate failed: %v", err) + } + assertGeneratedUnionDeclarations(t, genDir) + runGeneratedTests(t, genDir) +} + +// assertGeneratedUnionDeclarations proves identical request derivations reuse +// one union while the differently shaped response receives another. +func assertGeneratedUnionDeclarations(t *testing.T, genDir string) { + t.Helper() + path := filepath.Join(genDir, "http", "front", "server", "types.go") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read generated server types: %v", err) + } + code := string(content) + if strings.Count(code, "type Scope struct {") != 1 || + strings.Count(code, "type Scope2 struct {") != 1 { + t.Fatalf("expected one request union and one response union:\n%s", code) + } + if strings.Contains(code, "type Scope3 struct {") { + t.Fatalf("identical request derivation produced a third union declaration:\n%s", code) + } + if strings.Count(code, "type SiteSetRequestBody struct {") != 1 || + strings.Count(code, "type SiteSetResponseBody struct {") != 1 { + t.Fatalf("expected one request branch and one response branch declaration:\n%s", code) + } + if !strings.Contains(code, "Scope *Scope `") || + !strings.Contains(code, "Scope Scope2 `") { + t.Fatalf("request and response bodies do not use their released union names:\n%s", code) + } + if strings.Count(code, "\tSiteSet *SiteSetRequestBody\n") != 1 || + strings.Count(code, "\tSiteSet *SiteSetResponseBody\n") != 1 { + t.Fatalf("unions do not reference their request and response branches:\n%s", code) + } +} + +// writeGeneratedModule creates a temporary module that resolves this Goa +// checkout explicitly instead of downloading a released generator runtime. +func writeGeneratedModule(t *testing.T, dir, modulePath string) { + t.Helper() + goaRoot := moduleDirectory(t, "goa.design/goa/v3") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create generated module directory: %v", err) + } + module := "module " + modulePath + "\n\ngo 1.24\n\nrequire goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaRoot) + "\n" + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600); err != nil { + t.Fatalf("write generated go.mod: %v", err) + } +} + +// moduleDirectory returns the checked-out directory for module from the outer +// test environment. +func moduleDirectory(t *testing.T, module string) string { + t.Helper() + cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", module) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("resolve module %s: %v\n%s", module, err, output) + } + dir := strings.TrimSpace(string(output)) + if dir == "" { + t.Fatalf("resolve module %s: empty directory", module) + } + return dir +} + +// runGeneratedTests compiles every generated service and HTTP transport +// package in the isolated module. +func runGeneratedTests(t *testing.T, dir string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("compile generated packages: %v\n%s", err, output) + } +} diff --git a/codegen/generator/generate_merge_test.go b/codegen/generator/generate_merge_test.go index e4659adf66..d8967a7fbe 100644 --- a/codegen/generator/generate_merge_test.go +++ b/codegen/generator/generate_merge_test.go @@ -1,3 +1,5 @@ +// This file verifies file aggregation across core generators and plugins, +// including the separately assigned same-label section regression. package generator import ( @@ -7,51 +9,243 @@ import ( "strings" "testing" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" goa "goa.design/goa/v3/pkg" ) +// TestMergeFilesPreservesSameLabelSections verifies that diagnostic section +// labels do not cause the merger to discard different generated bodies. +func TestMergeFilesPreservesSameLabelSections(t *testing.T) { + finalizeMergeTestRoots(t) + registry := testRegistryFromGenfuncs([]testGenfunc{ + testRenderOnly(func(_ string, _ []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{ + Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type-def", Source: "type First struct{}\n"}, + }, + }}, nil + }), + testRenderOnly(func(_ string, _ []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{ + Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type-def", Source: "type Second struct{}\n"}, + }, + }}, nil + }), + }) + + dir := t.TempDir() + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(dir, codegen.Gendir, "types", "same_label.go")) + require.NoError(t, err) + require.Contains(t, string(content), "type First struct{}") + require.Contains(t, string(content), "type Second struct{}") +} + +// TestMergeFilesRunsEveryFinalizer verifies that same-path contributors keep +// their post-render work in the same order as their generated sections. +func TestMergeFilesRunsEveryFinalizer(t *testing.T) { + var calls []string + files, err := mergeFilesByPath([]*codegen.File{ + { + Path: "gen/types.go", + SectionTemplates: []*codegen.SectionTemplate{codegen.Header("Types", "gen", nil)}, + FinalizeFunc: func(string) error { + calls = append(calls, "first") + return nil + }, + }, + { + Path: "gen/types.go", + SectionTemplates: []*codegen.SectionTemplate{codegen.Header("Types", "gen", nil)}, + FinalizeFunc: func(string) error { + calls = append(calls, "second") + return nil + }, + }, + }) + require.NoError(t, err) + require.Len(t, files, 1) + require.NoError(t, files[0].FinalizeFunc("gen/types.go")) + require.Equal(t, []string{"first", "second"}, calls) +} + +// TestMergeFilesAllowsUnaliasedImports verifies that the merger does not guess +// Go package identifiers from import path spellings it does not own. +func TestMergeFilesAllowsUnaliasedImports(t *testing.T) { + first := mergeTestFile("types", false, []*codegen.ImportSpec{ + {Path: "first.example/v2"}, + }) + second := mergeTestFile("types", false, []*codegen.ImportSpec{ + {Path: "second.example/v2"}, + }) + + files, err := mergeFilesByPath([]*codegen.File{first, second}) + + require.NoError(t, err) + require.Len(t, files, 1) + header := files[0].SectionTemplates[0].Data.(map[string]any) + require.Len(t, header["Imports"], 2) +} + +// TestMergeFilesRejectsConflictingFileContracts verifies that the merger +// reports incompatible contributors instead of silently keeping one value. +func TestMergeFilesRejectsConflictingFileContracts(t *testing.T) { + tests := []struct { + name string + first *codegen.File + second *codegen.File + err string + }{ + { + name: "skip existing", + first: mergeTestFile("types", false, nil), + second: mergeTestFile("types", true, nil), + err: "conflicting SkipExist", + }, + { + name: "package", + first: mergeTestFile("first", false, nil), + second: mergeTestFile("second", false, nil), + err: "header packages", + }, + { + name: "alias", + first: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "shared", Path: "example.com/first"}, + }), + second: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "shared", Path: "example.com/second"}, + }), + err: "import name", + }, + { + name: "path", + first: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "first", Path: "example.com/shared"}, + }), + second: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "second", Path: "example.com/shared"}, + }), + err: "import path", + }, + { + name: "conflict within first header", + first: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "shared", Path: "first.example/value"}, + {Name: "shared", Path: "second.example/value"}, + }), + second: mergeTestFile("types", false, nil), + err: "import name", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := mergeFilesByPath([]*codegen.File{test.first, test.second}) + require.ErrorContains(t, err, test.err) + }) + } +} + +// TestMergeFilesCanonicalizesOutputPaths verifies that contributors targeting +// one cleaned relative file cannot bypass compatibility checks or race writes. +func TestMergeFilesCanonicalizesOutputPaths(t *testing.T) { + first := mergeTestFile("types", false, nil) + first.Path = "gen/types.go" + first.SectionTemplates = append(first.SectionTemplates, &codegen.SectionTemplate{ + Name: "first", + Source: "type First struct{}", + }) + second := mergeTestFile("types", false, nil) + second.Path = "gen/x/../types.go" + second.SectionTemplates = append(second.SectionTemplates, &codegen.SectionTemplate{ + Name: "second", + Source: "type Second struct{}", + }) + + files, err := mergeFilesByPath([]*codegen.File{first, second}) + + require.NoError(t, err) + require.Len(t, files, 1) + require.Equal(t, filepath.Join("gen", "types.go"), files[0].Path) + require.Len(t, files[0].SectionTemplates, 3) +} + +// TestMergeFilesRejectsUnsafeOutputPaths verifies that no generated file can +// escape the output directory or collide only on a portable filesystem. +func TestMergeFilesRejectsUnsafeOutputPaths(t *testing.T) { + tests := []struct { + name string + paths []string + err string + }{ + {"parent", []string{"../outside.go"}, "must stay within"}, + {"absolute", []string{filepath.Join(string(filepath.Separator), "outside.go")}, "must stay within"}, + {"volume", []string{`C:\outside.go`}, "not portable"}, + {"case fold", []string{"gen/Types.go", "gen/types.go"}, "case-insensitive"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + files := make([]*codegen.File, len(test.paths)) + for index, outputPath := range test.paths { + files[index] = mergeTestFile("types", false, nil) + files[index].Path = outputPath + } + _, err := mergeFilesByPath(files) + require.ErrorContains(t, err, test.err) + }) + } +} + // TestGenerateMergesSamePathFiles verifies that when two generators emit content // targeting the same output path, Generate merges the sections into a single // file rather than overwriting earlier content. This is a regression test for // an issue where only a later section (e.g., a union value method) remained and // the earlier struct definition was lost. func TestGenerateMergesSamePathFiles(t *testing.T) { - t.Cleanup(func() { Generators = generators }) + finalizeMergeTestRoots(t) // Fake generators emit two files with identical Path, one containing a // type definition and the other containing a method. Without merging, the // second write would overwrite the first. - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("User types", "types", nil), - { // struct definition - Name: "struct-type", - Source: "type MergeTest struct{}\n", - }, - } - return []*codegen.File{f}, nil - }, - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("User types", "types", nil), - { // method on MergeTest - Name: "method", - Source: "func (*MergeTest) Marker() {}\n", - }, - } - return []*codegen.File{f}, nil - }, - }, nil - } + registry := testRegistryFromGenfuncs([]testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("User types", "types", nil), + { // struct definition + Name: "struct-type", + Source: "type MergeTest struct{}\n", + }, + } + return []*codegen.File{f}, nil + }), + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("User types", "types", nil), + { // method on MergeTest + Name: "method", + Source: "func (*MergeTest) Marker() {}\n", + }, + } + return []*codegen.File{f}, nil + }), + }) dir := t.TempDir() - _, err := Generate(dir, "gen", false) + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + _, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) } @@ -76,35 +270,34 @@ func TestGenerateMergesSamePathFiles(t *testing.T) { // pool distribution. This ensures all workers process files and all files are // written correctly. func TestGenerateParallelManyFiles(t *testing.T) { - t.Cleanup(func() { Generators = generators }) + finalizeMergeTestRoots(t) // Generate 20 files to ensure we exceed typical CPU counts and exercise // the worker pool's work distribution. const numFiles = 20 - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - files := make([]*codegen.File, numFiles) - for i := 0; i < numFiles; i++ { - f := &codegen.File{ - Path: filepath.Join(codegen.Gendir, "types", filepath.Join("parallel", filepath.Join("file"+string(rune('a'+i%26)), "test"+string(rune('0'+i/26))+".go"))), - } - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - { - Name: "type-def", - Source: "type Test" + string(rune('A'+i)) + " struct{ ID int }\n", - }, - } - files[i] = f + registry := testRegistryFromGenfuncs([]testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + files := make([]*codegen.File, numFiles) + for i := 0; i < numFiles; i++ { + f := &codegen.File{ + Path: filepath.Join(codegen.Gendir, "types", filepath.Join("parallel", filepath.Join("file"+string(rune('a'+i%26)), "test"+string(rune('0'+i/26))+".go"))), } - return files, nil - }, - }, nil - } + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + { + Name: "type-def", + Source: "type Test" + string(rune('A'+i)) + " struct{ ID int }\n", + }, + } + files[i] = f + } + return files, nil + }), + }) dir := t.TempDir() - outputs, err := Generate(dir, "gen", false) + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + outputs, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) } @@ -134,41 +327,40 @@ func TestGenerateParallelManyFiles(t *testing.T) { // handles file merging when multiple generators target the same path. This // tests the interaction between mergeFilesByPath and parallel rendering. func TestGenerateParallelWithMerge(t *testing.T) { - t.Cleanup(func() { Generators = generators }) + finalizeMergeTestRoots(t) // Three generators: first two merge to same path, third is separate. // This exercises both merging and parallel writing with NumCPU workers. - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f1 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} - f1.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type1", Source: "type Type1 struct{}\n"}, - } - return []*codegen.File{f1}, nil - }, - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f2 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} - f2.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type2", Source: "type Type2 struct{}\n"}, - } - return []*codegen.File{f2}, nil - }, - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f3 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "separate.go")} - f3.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type3", Source: "type Type3 struct{}\n"}, - } - return []*codegen.File{f3}, nil - }, - }, nil - } + registry := testRegistryFromGenfuncs([]testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f1 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} + f1.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type1", Source: "type Type1 struct{}\n"}, + } + return []*codegen.File{f1}, nil + }), + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f2 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} + f2.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type2", Source: "type Type2 struct{}\n"}, + } + return []*codegen.File{f2}, nil + }), + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f3 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "separate.go")} + f3.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type3", Source: "type Type3 struct{}\n"}, + } + return []*codegen.File{f3}, nil + }), + }) dir := t.TempDir() - outputs, err := Generate(dir, "gen", false) + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + outputs, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) } @@ -208,38 +400,37 @@ func TestGenerateParallelWithMerge(t *testing.T) { // in the parallel worker pool, the first error is captured and returned while // other workers continue processing. func TestGenerateParallelErrorHandling(t *testing.T) { - t.Cleanup(func() { Generators = generators }) + finalizeMergeTestRoots(t) // Create multiple files where some will fail to render due to invalid paths. // Worker pool should capture first error but continue processing other files. - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - files := make([]*codegen.File, 5) - for i := 0; i < 5; i++ { - f := &codegen.File{ - Path: filepath.Join(codegen.Gendir, "types", "file"+string(rune('0'+i))+".go"), - } - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type", Source: "type T" + string(rune('0'+i)) + " struct{}\n"}, - } - // Make file 2 fail by adding an invalid path character after writing starts - if i == 2 { - // Use a FinalizeFunc that returns an error - f.FinalizeFunc = func(fp string) error { - return os.ErrInvalid - } + registry := testRegistryFromGenfuncs([]testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + files := make([]*codegen.File, 5) + for i := 0; i < 5; i++ { + f := &codegen.File{ + Path: filepath.Join(codegen.Gendir, "types", "file"+string(rune('0'+i))+".go"), + } + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type", Source: "type T" + string(rune('0'+i)) + " struct{}\n"}, + } + // Make file 2 fail by adding an invalid path character after writing starts + if i == 2 { + // Use a FinalizeFunc that returns an error + f.FinalizeFunc = func(fp string) error { + return os.ErrInvalid } - files[i] = f } - return files, nil - }, - }, nil - } + files[i] = f + } + return files, nil + }), + }) dir := t.TempDir() - _, err := Generate(dir, "gen", false) + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + _, err := generate(dir, "gen", false, registry) if err == nil { t.Fatal("expected error from parallel generation, got nil") } @@ -252,23 +443,22 @@ func TestGenerateParallelErrorHandling(t *testing.T) { // TestGenerateParallelSingleFile verifies that parallel file writing works // correctly with just a single file (minimal parallelism edge case). func TestGenerateParallelSingleFile(t *testing.T) { - t.Cleanup(func() { Generators = generators }) + finalizeMergeTestRoots(t) - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "single.go")} - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type", Source: "type Single struct{}\n"}, - } - return []*codegen.File{f}, nil - }, - }, nil - } + registry := testRegistryFromGenfuncs([]testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "single.go")} + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type", Source: "type Single struct{}\n"}, + } + return []*codegen.File{f}, nil + }), + }) dir := t.TempDir() - outputs, err := Generate(dir, "gen", false) + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + outputs, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) } @@ -318,3 +508,25 @@ func assertVersionFile(t *testing.T, dir string, outputs []string) []string { } return rest } + +// mergeTestFile creates one complete Go file contribution for merger tests. +func mergeTestFile(packageName string, skipExist bool, imports []*codegen.ImportSpec) *codegen.File { + return &codegen.File{ + Path: "gen/types.go", + SkipExist: skipExist, + SectionTemplates: []*codegen.SectionTemplate{codegen.Header("Types", packageName, imports)}, + } +} + +// finalizeMergeTestRoots supplies the evaluated-design precondition that the +// filesystem-facing generator receives from the goa command in production. +func finalizeMergeTestRoots(t *testing.T) { + t.Helper() + roots, err := eval.Context.Roots() + require.NoError(t, err) + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + design.Finalize() + } + } +} diff --git a/codegen/generator/generate_union_merge_integration_test.go b/codegen/generator/generate_union_merge_integration_test.go index b4bfc7dbcf..9eb5026454 100644 --- a/codegen/generator/generate_union_merge_integration_test.go +++ b/codegen/generator/generate_union_merge_integration_test.go @@ -1,3 +1,5 @@ +// This file verifies that complete generation merges shared union +// declarations without losing their package-owned names. package generator import ( @@ -16,8 +18,12 @@ import ( // the union marker method for the union branch type. This mirrors the original // failure mode where only the union method remained and the struct was lost. func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(cmd string) ([]Genfunc, error) { return []Genfunc{Service, Transport, OpenAPI}, nil } + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + testGenerator(planOpenAPIData, testOpenAPIFiles), + ) dsl := func() { d.API("test", func() {}) @@ -55,7 +61,8 @@ func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { _ = cg.RunDSL(t, dsl) dir := t.TempDir() - if _, err := Generate(dir, "gen", false); err != nil { + writeGeneratedModule(t, filepath.Join(dir, cg.Gendir), "generated.local/gen") + if _, err := generate(dir, "gen", false, registry); err != nil { t.Fatalf("Generate failed: %v", err) } diff --git a/codegen/generator/generated_grpc_shared_package_integration_test.go b/codegen/generator/generated_grpc_shared_package_integration_test.go new file mode 100644 index 0000000000..c3fd778f32 --- /dev/null +++ b/codegen/generator/generated_grpc_shared_package_integration_test.go @@ -0,0 +1,128 @@ +// This file checks two gRPC designs that write service, client, and server +// files into the same directories. Every written file must use the names +// chosen for both designs before generation starts. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedGRPCPackagesCompileAcrossSharedPackageRoots checks both design +// orders because input order must not change the names in shared directories. +func TestGeneratedGRPCPackagesCompileAcrossSharedPackageRoots(t *testing.T) { + tests := []struct { + name string + reverse bool + }{ + {name: "forward"}, + {name: "reverse", reverse: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + first := grpcSharedPackageRoot(t, "Shared", "First") + second := grpcSharedPackageRoot(t, "Shared", "Second") + roots := []eval.Root{first, second} + if test.reverse { + roots[0], roots[1] = roots[1], roots[0] + } + + reserveValidator := func(plan *Plan) error { + pkg, err := plan.Generation().ClaimPackage("generated.local/gen/grpc/shared/server") + if err != nil { + return err + } + for _, name := range []string{"ValidateSyncRequest", "ValidateExchangeRequest", "ValidateExchangeStreamingRequest"} { + if err := pkg.DeclareName(codegen.NewExactName(codegen.NameFunction, name)); err != nil { + return err + } + } + return nil + } + plan := mustTestPlan(t, "generated.local/gen", roots, planTransportData, reserveValidator) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + files = append(files, &codegen.File{ + Path: "gen/grpc/shared/server/validator_owner.go", + SectionTemplates: []*codegen.SectionTemplate{ + { + Name: "validator-owner", + Source: `package server + +func ValidateSyncRequest() {} +func ValidateExchangeRequest() {} +func ValidateExchangeStreamingRequest() {}`, + }, + }, + }) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) + }) + } +} + +// grpcSharedPackageRoot returns one design whose service name chooses the +// output directories. typePrefix keeps its values separate from the other +// design that writes into those directories. +func grpcSharedPackageRoot(t *testing.T, serviceName, typePrefix string) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.API(typePrefix, func() {}) + node := dsl.Type(typePrefix+"Node", func() { + dsl.Field(1, "name", dsl.String) + dsl.Field(2, "next", typePrefix+"Node") + dsl.Required("name") + }) + payload := dsl.Type(typePrefix+"Payload", func() { + dsl.Field(1, "id", dsl.String) + dsl.Field(2, "node", node) + dsl.Required("id", "node") + }) + result := dsl.Type(typePrefix+"Result", func() { + dsl.Field(1, "status", dsl.String) + dsl.Field(2, "node", node) + dsl.Required("status", "node") + }) + failure := dsl.Type(typePrefix+"Failure", func() { + dsl.Field(1, "message", dsl.String) + dsl.Required("message") + }) + + dsl.Service(serviceName, func() { + dsl.Error("failed", failure) + dsl.GRPC(func() { + dsl.Response("failed", dsl.CodeInvalidArgument) + }) + dsl.Method("Sync", func() { + dsl.Payload(payload) + dsl.Result(result) + dsl.Error("failed", failure) + dsl.GRPC(func() {}) + }) + dsl.Method("Exchange", func() { + dsl.Payload(payload) + dsl.StreamingPayload(payload) + dsl.StreamingResult(result) + dsl.Error("failed", failure) + dsl.GRPC(func() {}) + }) + }) + }) +} diff --git a/codegen/generator/generated_package_import_test.go b/codegen/generator/generated_package_import_test.go new file mode 100644 index 0000000000..408722acfc --- /dev/null +++ b/codegen/generator/generated_package_import_test.go @@ -0,0 +1,347 @@ +// This file verifies that generation accepts only importable package identities +// returned by Go's package loader and never invents an import path. +package generator + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/tools/go/packages" +) + +func TestGeneratedPackageImportPath(t *testing.T) { + t.Run("module package", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + moduleDir := t.TempDir() + writePackageFixture(t, moduleDir, "module generated.local\n\ngo 1.25\n") + + got, err := generatedPackageImportPath(filepath.Join(moduleDir, "gen")) + require.NoError(t, err) + require.Equal(t, "generated.local/gen", got) + }) + + t.Run("authored underscore module", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + moduleDir := t.TempDir() + writePackageFixture(t, moduleDir, "module _/authored\n\ngo 1.25\n") + + got, err := generatedPackageImportPath(filepath.Join(moduleDir, "gen")) + require.NoError(t, err) + require.Equal(t, "_/authored/gen", got) + }) + + t.Run("workspace module", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + workspaceDir := t.TempDir() + otherDir := filepath.Join(workspaceDir, "other") + targetDir := filepath.Join(workspaceDir, "target") + require.NoError(t, os.MkdirAll(otherDir, 0o750)) + require.NoError(t, os.MkdirAll(targetDir, 0o750)) + writePackageFixture(t, otherDir, "module workspace.local/other\n\ngo 1.25\n") + writePackageFixture(t, targetDir, "module _/target\n\ngo 1.25\n") + workFile := filepath.Join(workspaceDir, "go.work") + require.NoError(t, os.WriteFile(workFile, []byte("go 1.25\n\nuse (\n\t./other\n\t./target\n)\n"), 0o600)) + t.Setenv("GOWORK", workFile) + + got, err := generatedPackageImportPath(filepath.Join(targetDir, "gen")) + require.NoError(t, err) + require.Equal(t, "_/target/gen", got) + }) + + t.Run("GOPATH package", func(t *testing.T) { + gopath := t.TempDir() + packageDir := filepath.Join(gopath, "src", "_", "foo") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package foo\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, + Dir: packageDir, + }, ".") + require.NoError(t, err) + require.Len(t, pkgs, 1) + require.Empty(t, pkgs[0].Errors) + require.Nil(t, pkgs[0].Module) + require.Equal(t, packageDir, pkgs[0].Dir) + require.Equal(t, "_/foo", pkgs[0].PkgPath) + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/foo", got) + }) + + t.Run("GOPATH package reached through symlink", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows symlinks require privileges not available on every test host") + } + gopath := t.TempDir() + realPackageDir := filepath.Join(gopath, "src", "_", "linked") + require.NoError(t, os.MkdirAll(realPackageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(realPackageDir, "generated.go"), []byte("package linked\n"), 0o600)) + linkedGOPATH := filepath.Join(t.TempDir(), "linked-gopath") + require.NoError(t, os.Symlink(gopath, linkedGOPATH)) + linkedPackageDir := filepath.Join(linkedGOPATH, "src", "_", "linked") + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, + Dir: linkedPackageDir, + }, ".") + require.NoError(t, err) + require.Len(t, pkgs, 1) + require.Empty(t, pkgs[0].Errors) + require.Nil(t, pkgs[0].Module) + require.Equal(t, realPackageDir, pkgs[0].Dir) + require.Equal(t, "_/linked", pkgs[0].PkgPath) + + owned, err := gopathOwnsImportPath(linkedPackageDir, "_/linked") + require.NoError(t, err) + require.True(t, owned) + + got, err := generatedPackageImportPath(linkedPackageDir) + require.NoError(t, err) + require.Equal(t, "_/linked", got) + }) + + t.Run("missing GOPATH package", func(t *testing.T) { + gopath := filepath.Join(t.TempDir(), "missing") + importPath := "_/missing" + lexicalDir := filepath.Join(gopath, "src", filepath.FromSlash(importPath)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + owned, err := gopathOwnsImportPath(lexicalDir, importPath) + require.NoError(t, err) + require.True(t, owned) + + owned, err = gopathOwnsImportPath(t.TempDir(), importPath) + require.NoError(t, err) + require.False(t, owned) + }) + + t.Run("GOPATH symlink resolution error", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows symlinks require privileges not available on every test host") + } + gopath := t.TempDir() + importPath := "_/loop" + packagePath := filepath.Join(gopath, "src", filepath.FromSlash(importPath)) + require.NoError(t, os.MkdirAll(filepath.Dir(packagePath), 0o750)) + require.NoError(t, os.Symlink(packagePath, packagePath)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + owned, err := gopathOwnsImportPath(t.TempDir(), importPath) + require.Error(t, err) + require.False(t, owned) + require.ErrorContains(t, err, "resolve GOPATH package path") + }) + + t.Run("non-first GOPATH package", func(t *testing.T) { + first := t.TempDir() + second := t.TempDir() + packageDir := filepath.Join(second, "src", "_", "second") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package second\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", strings.Join([]string{first, second}, string(os.PathListSeparator))) + t.Setenv("GOPACKAGESDRIVER", "off") + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/second", got) + }) + + t.Run("GOPATH ending in space", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows paths cannot portably end in a space") + } + gopath := filepath.Join(t.TempDir(), "gopath ") + packageDir := filepath.Join(gopath, "src", "_", "space") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package space\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/space", got) + }) + + t.Run("GOENV GOPATH package", func(t *testing.T) { + gopath := t.TempDir() + packageDir := filepath.Join(gopath, "src", "_", "goenv") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package goenv\n"), 0o600)) + goenvFile := filepath.Join(t.TempDir(), "go.env") + require.NoError(t, os.WriteFile(goenvFile, []byte("GOPATH="+gopath+"\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", goenvFile) + t.Setenv("GOPACKAGESDRIVER", "off") + unsetTestEnv(t, "GOPATH") + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/goenv", got) + }) + + t.Run("empty GOPATH uses default", func(t *testing.T) { + home := t.TempDir() + gopath := filepath.Join(home, "go") + packageDir := filepath.Join(gopath, "src", "_", "default") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package defaultpkg\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", "") + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("GOPACKAGESDRIVER", "off") + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/default", got) + }) + + t.Run("synthetic GOPATH package", func(t *testing.T) { + gopath := filepath.Join(t.TempDir(), "gopath") + require.NoError(t, os.MkdirAll(gopath, 0o750)) + packageDir := filepath.Join(t.TempDir(), "outside") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package gen\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, + Dir: packageDir, + }, ".") + require.NoError(t, err) + require.Len(t, pkgs, 1) + require.Empty(t, pkgs[0].Errors) + require.Nil(t, pkgs[0].Module) + require.Equal(t, packageDir, pkgs[0].Dir) + require.True(t, strings.HasPrefix(pkgs[0].PkgPath, "_/"), pkgs[0].PkgPath) + + got, err := generatedPackageImportPath(packageDir) + require.Error(t, err) + require.Empty(t, got) + require.ErrorContains(t, err, "synthetic import path") + }) + + t.Run("synthetic package with missing GOPATH roots", func(t *testing.T) { + first := filepath.Join(t.TempDir(), "missing-first") + second := filepath.Join(t.TempDir(), "missing-second") + packageDir := filepath.Join(t.TempDir(), "outside") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package gen\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", strings.Join([]string{first, second}, string(os.PathListSeparator))) + t.Setenv("GOPACKAGESDRIVER", "off") + + got, err := generatedPackageImportPath(packageDir) + require.Error(t, err) + require.Empty(t, got) + require.ErrorContains(t, err, "synthetic import path") + }) + + t.Run("invalid package", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + moduleDir := t.TempDir() + writePackageFixture(t, moduleDir, "module generated.local\n\ngo 1.25\n") + genDir := filepath.Join(moduleDir, "gen") + require.NoError(t, os.WriteFile(filepath.Join(genDir, "other.go"), []byte("package other\n"), 0o600)) + + got, err := generatedPackageImportPath(genDir) + require.Error(t, err) + require.Empty(t, got) + var packageError packages.Error + require.ErrorAs(t, err, &packageError) + }) + + t.Run("invalid module", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + moduleDir := t.TempDir() + writePackageFixture(t, moduleDir, "module invalid path\n\ngo 1.25\n") + + got, err := generatedPackageImportPath(filepath.Join(moduleDir, "gen")) + require.Error(t, err) + require.Empty(t, got) + require.ErrorContains(t, err, "errors parsing") + }) +} + +// unsetTestEnv removes key for one subtest and restores its exact process +// state after the loader has observed the missing variable. +func unsetTestEnv(t *testing.T, key string) { + t.Helper() + value, exists := os.LookupEnv(key) + require.NoError(t, os.Unsetenv(key)) + t.Cleanup(func() { + if exists { + require.NoError(t, os.Setenv(key, value)) + return + } + require.NoError(t, os.Unsetenv(key)) + }) +} + +// writePackageFixture creates the module and generated package consumed by the +// real package loader in each test case. +func writePackageFixture(t *testing.T, moduleDir, moduleFile string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(moduleFile), 0o600)) + genDir := filepath.Join(moduleDir, "gen") + require.NoError(t, os.MkdirAll(genDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(genDir, "generated.go"), []byte("package gen\n"), 0o600)) +} diff --git a/codegen/generator/generated_service_path_integration_test.go b/codegen/generator/generated_service_path_integration_test.go new file mode 100644 index 0000000000..16791f6208 --- /dev/null +++ b/codegen/generator/generated_service_path_integration_test.go @@ -0,0 +1,125 @@ +// This file checks that every generated transport and example uses the service +// directory selected by the shared service plan. +package generator + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedTransportsUsePlannedServicePaths checks both design orders so +// adding a suffix to one service directory cannot redirect another service's +// HTTP, JSON-RPC, gRPC, command-line, or example imports. +func TestGeneratedTransportsUsePlannedServicePaths(t *testing.T) { + tests := []struct { + name string + reverse bool + }{ + {name: "forward"}, + {name: "reverse", reverse: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := servicePathRoot(t, test.reverse) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + expected := map[string]string{ + "read-value": "read_value", + "read_value": "read_value3", + "read_value2": "read_value2", + } + for _, service := range root.Services { + require.Equal(t, expected[service.Name], plan.Service(root).Services().Get(service.Name).PathName) + } + + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + generatedPaths := make(map[string]struct{}, len(files)) + for _, file := range files { + generatedPaths[filepath.ToSlash(file.Path)] = struct{}{} + } + for _, servicePath := range expected { + for _, filePath := range []string{ + "gen/" + servicePath + "/service.go", + "gen/http/" + servicePath + "/server/server.go", + "gen/jsonrpc/" + servicePath + "/server/server.go", + "gen/grpc/" + servicePath + "/server/server.go", + } { + _, ok := generatedPaths[filePath] + require.True(t, ok, "missing generated file %s", filePath) + } + } + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) + }) + } +} + +// servicePathRoot returns one API whose services exercise every generated +// transport and both one-shot and streaming JSON-RPC output. +func servicePathRoot(t *testing.T, reverse bool) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + names := []string{"read-value", "read_value", "read_value2"} + if reverse { + names[0], names[2] = names[2], names[0] + } + servers := map[string]string{ + "read-value": "dash", + "read_value": "underscore", + "read_value2": "numbered", + } + dsl.API("path api", func() { + for _, serviceName := range names { + dsl.Server(servers[serviceName], func() { + dsl.Services(serviceName) + dsl.Host("local", func() { dsl.URI("http://localhost") }) + }) + } + }) + for _, serviceName := range names { + dsl.Service(serviceName, func() { + dsl.Method("HTTP call", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { dsl.POST("/call") }) + }) + dsl.Method("JSON-RPC call", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + dsl.Method("JSON-RPC stream", func() { + dsl.Payload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { dsl.ServerSentEvents() }) + }) + dsl.Method("gRPC call", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + }) + } + }) +} diff --git a/codegen/generator/generated_transport_alias_integration_test.go b/codegen/generator/generated_transport_alias_integration_test.go new file mode 100644 index 0000000000..ae30f3d228 --- /dev/null +++ b/codegen/generator/generated_transport_alias_integration_test.go @@ -0,0 +1,283 @@ +// This file checks that generated files use the exact package names assigned +// before the files are written. +package generator + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + httpHelperCollisionOrder string + jsonRPCSharedPackageMode uint8 +) + +const ( + jsonRPCUnary jsonRPCSharedPackageMode = iota + jsonRPCSSE +) + +// ComparePackageName orders names added by the collision test. +func (o httpHelperCollisionOrder) ComparePackageName(other codegen.PackageNameOrder) int { + return strings.Compare(string(o), string(other.(httpHelperCollisionOrder))) +} + +// TestGeneratedTransportPackagesCompileWithServiceAliasCollisions proves that +// client, server, protobuf, command-line, and service imports still name the +// service they belong to when service names produce the same Go import name. +func TestGeneratedTransportPackagesCompileWithServiceAliasCollisions(t *testing.T) { + root := codegen.RunDSL(t, func() { + interceptor := dsl.Interceptor("Trace", func() {}) + for _, name := range []string{"Foo", "Fooc", "Foosvr", "Foojssvr"} { + dsl.Service(name, func() { + if name == "Foo" { + dsl.ClientInterceptor(interceptor) + } + dsl.Method("Read", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { dsl.POST("/" + strings.ToLower(name)) }) + dsl.GRPC(func() {}) + }) + dsl.Method("ReadJSON", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + } + }) + + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transport, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transport...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) +} + +// TestGeneratedCLICompilesWhenImportsMatchLocalNames verifies that endpoint +// parsers keep using package imports when a generated local prefers the same name. +func TestGeneratedCLICompilesWhenImportsMatchLocalNames(t *testing.T) { + root := codegen.RunDSL(t, func() { + trace := dsl.Interceptor("Trace", func() {}) + message := dsl.Type("Message", func() { + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("C", func() { + dsl.Method("Read", func() { + dsl.Payload(message) + dsl.StreamingResult(message) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("Data", func() { + dsl.ClientInterceptor(trace) + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/data") }) + }) + }) + }) + + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + runGeneratedTests(t, directory) +} + +// TestGRPCOnlyExamplesCompile checks that a server with no HTTP service does +// not refer to an HTTP command-line flag. +func TestGRPCOnlyExamplesCompile(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Echo", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) +} + +// TestGeneratedHTTPHelpersCompileWithPackageNameCollisions checks that file and +// mixed-result stream helpers use their chosen names in definitions and calls. +func TestGeneratedHTTPHelpersCompileWithPackageNameCollisions(t *testing.T) { + root := httpHelperCollisionRoot(t, "Foo Bar", "First") + reserve := func(plan *Plan) error { + pkg, err := plan.Generation().ClaimPackage("generated.local/gen/http/foo_bar/server") + if err != nil { + return err + } + declarations := []*codegen.NameDeclaration{ + codegen.NewPreferredName(codegen.NameType, "appendFS", codegen.UnexportedName, httpHelperCollisionOrder("append-fs")), + codegen.NewPreferredName(codegen.NameFunction, "appendPrefix", codegen.UnexportedName, httpHelperCollisionOrder("append-prefix")), + codegen.NewPreferredName(codegen.NameType, "discardCreateServerStream", codegen.UnexportedName, httpHelperCollisionOrder("discard-stream")), + } + for _, declaration := range declarations { + if err := pkg.DeclareName(declaration); err != nil { + return err + } + } + return nil + } + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData, reserve, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + protocolFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, protocolFiles...) + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) +} + +// TestGeneratedJSONRPCPackagesCompileAcrossSharedPackageRoots checks that each +// generated client and server uses the names chosen for its own declarations +// when two designs write files into the same Go packages. +func TestGeneratedJSONRPCPackagesCompileAcrossSharedPackageRoots(t *testing.T) { + tests := []struct { + name string + mode jsonRPCSharedPackageMode + }{ + {name: "ordinary", mode: jsonRPCUnary}, + {name: "server sent events", mode: jsonRPCSSE}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + first := jsonRPCSharedPackageRoot(t, "Shared", "First", test.mode) + second := jsonRPCSharedPackageRoot(t, "Shared", "Second", test.mode) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{first, second}, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + for _, root := range []*expr.RootExpr{first, second} { + jsonPlan := plan.jsonrpc[root] + files = append(files, jsonPlan.ServerFiles()...) + files = append(files, jsonPlan.ClientFiles()...) + files = append(files, jsonPlan.ServerTypeFiles()...) + files = append(files, jsonPlan.ClientTypeFiles()...) + files = append(files, jsonPlan.PathFiles()...) + } + files, err = mergeFilesByPath(files) + require.NoError(t, err) + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) + }) + } +} + +// jsonRPCSharedPackageRoot returns one design whose service name controls the +// generated directory. typePrefix keeps its service types separate from the +// other design that writes into the same directory. Every design uses the Call +// method, so their stream types and constructors request the same Go names. +func jsonRPCSharedPackageRoot(t *testing.T, serviceName, typePrefix string, mode jsonRPCSharedPackageMode) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.API(typePrefix, func() {}) + payload := dsl.Type(typePrefix+"Payload", func() { + dsl.Attribute("value", dsl.String) + }) + result := dsl.Type(typePrefix+"Result", func() { + dsl.Attribute("value", dsl.String) + }) + dsl.Service(serviceName, func() { + dsl.Method("Call", func() { + switch mode { + case jsonRPCUnary: + dsl.Payload(payload) + dsl.Result(result) + dsl.JSONRPC(func() {}) + case jsonRPCSSE: + dsl.Payload(payload) + dsl.StreamingResult(result) + dsl.JSONRPC(func() { dsl.ServerSentEvents() }) + default: + panic("unknown JSON-RPC test mode") + } + }) + }) + }) +} + +// httpHelperCollisionRoot returns one design with mixed HTTP results and a +// mapped file. serviceName controls the output directory and typePrefix keeps +// the service types distinct when two designs share that directory. +func httpHelperCollisionRoot(t *testing.T, serviceName, typePrefix string) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + payload := dsl.Type(typePrefix+"Payload", func() { + dsl.Attribute("value", dsl.String) + }) + result := dsl.Type(typePrefix+"Result", func() { + dsl.Attribute("value", dsl.String) + }) + event := dsl.Type(typePrefix+"Event", func() { + dsl.Attribute("value", dsl.String) + }) + dsl.Service(serviceName, func() { + dsl.Method("Create", func() { + dsl.Payload(payload) + dsl.Result(result) + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.POST("/create") + dsl.ServerSentEvents() + }) + }) + dsl.Files("/asset.json", "/embedded/file.json") + }) + }) +} diff --git a/codegen/generator/generation_test.go b/codegen/generator/generation_test.go new file mode 100644 index 0000000000..2753343830 --- /dev/null +++ b/codegen/generator/generation_test.go @@ -0,0 +1,212 @@ +// This file checks that every package name is chosen before core generators or +// plugins write files. +package generator + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpdata "goa.design/goa/v3/http/codegen/testdata" +) + +func TestGeneratePhasesShareOneGeneration(t *testing.T) { + command := fmt.Sprintf("test-generation-phases-%p", t) + root := codegen.RunDSL(t, func() {}) + + var ( + events []string + planned *codegen.Generation + lateDeclare error + preparedRoots []eval.Root + ) + typesPath := "generated.local/gen/types" + union := &expr.Union{TypeName: "Value", TypeKey: "type", ValueKey: "value"} + lateUnion := &expr.Union{TypeName: "Late", TypeKey: "kind", ValueKey: "data"} + + assertGeneration := func(generation *codegen.Generation) error { + if planned != generation { + return fmt.Errorf("generation changed between plan and render") + } + if len(generation.Roots()) != len(preparedRoots) { + return fmt.Errorf("generation roots changed after plugin preparation") + } + return nil + } + registry := newRegistry() + registry.addCommand(command, + func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { + events = append(events, "core-plan-first") + planned = plan.Generation() + typesPath = planned.GenPkg() + "/types" + types, err := planned.ClaimPackage(typesPath) + if err != nil { + return err + } + _, err = types.DeclareUnion(union) + return err + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + events = append(events, "core-render-first") + generation := plan.Generation() + if err := assertGeneration(generation); err != nil { + return nil, err + } + declaration, err := generation.Package(typesPath).Union(union) + if err != nil { + return nil, err + } + if declaration.Name() == "" { + return nil, fmt.Errorf("union name is empty during render") + } + _, lateDeclare = generation.Package(typesPath).DeclareUnion(lateUnion) + if lateDeclare == nil { + return nil, fmt.Errorf("render declared a new union after freeze") + } + return nil, nil + }, + } + }, + func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { + events = append(events, "core-plan-second") + return assertGeneration(plan.Generation()) + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + events = append(events, "core-render-second") + return nil, assertGeneration(plan.Generation()) + }, + } + }, + ) + registry.registerPlugin( + "lifecycle", + command, + pluginNormal, + func() Plugin { + return Plugin{ + Prepare: func(_ string, roots []eval.Root) error { + events = append(events, "plugin-prepare") + preparedRoots = roots + return nil + }, + Plan: func(plan *Plan) error { + events = append(events, "plugin-plan") + return assertGeneration(plan.Generation()) + }, + Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + events = append(events, "plugin-render") + return files, assertGeneration(plan.Generation()) + }, + } + }, + ) + + err := executeGeneration("generated.local/gen", []eval.Root{root}, command, registry) + require.NoError(t, err) + require.ErrorContains(t, lateDeclare, "frozen") + require.Equal(t, []string{ + "plugin-prepare", + "core-plan-first", + "core-plan-second", + "plugin-plan", + "core-render-first", + "core-render-second", + "plugin-render", + }, events) +} + +func TestCommandsPlanOnlyTheirFiles(t *testing.T) { + root := codegen.RunDSL(t, httpdata.AliasTypeDSL) + + genRun, err := newGenerationRun("gen", newDefaultRegistry()) + require.NoError(t, err) + genResult, err := genRun.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.Nil(t, genResult.plan.example) + require.NotNil(t, genResult.plan.openapi) + + exampleRun, err := newGenerationRun("example", newDefaultRegistry()) + require.NoError(t, err) + exampleResult, err := exampleRun.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.NotNil(t, exampleResult.plan.example) + require.Nil(t, exampleResult.plan.openapi) +} + +// TestRenderUsesRetainedPlans proves that file rendering does not look up +// services or transports from the prepared design after planning finishes. +func TestRenderUsesRetainedPlans(t *testing.T) { + root := codegen.RunDSL(t, httpdata.AliasTypeDSL) + plan := mustTestPlan( + t, + "generated.local/gen", + []eval.Root{root}, + planServiceData, + planTransportData, + ) + + plan.preparedRoots = nil + plan.services = nil + plan.http = nil + plan.jsonrpcHTTP = nil + plan.jsonrpc = nil + plan.grpc = nil + + serviceFiles, err := serviceFiles(plan) + require.NoError(t, err) + require.NotEmpty(t, serviceFiles) + transportFiles, err := transportFiles(plan) + require.NoError(t, err) + require.NotEmpty(t, transportFiles) +} + +// TestPreparedRootsRejectFileRenderMutation proves that persistent mutations +// made by templates and file finalizers are rejected after rendering completes. +func TestPreparedRootsRejectFileRenderMutation(t *testing.T) { + for _, phase := range []string{"template", "finalizer"} { + t.Run(phase, func(t *testing.T) { + root := codegen.RunDSL(t, httpdata.AliasTypeDSL) + dir := t.TempDir() + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + mutate := func() { + root.API.HTTP.Services[0].HTTPEndpoints[0].Routes[0].Path = "/changed" + } + first := &codegen.File{ + Path: "first.txt", + SectionTemplates: []*codegen.SectionTemplate{{ + Name: "first", + Source: "first", + }}, + } + if phase == "template" { + first.SectionTemplates[0].Source = "{{ mutate }}" + first.SectionTemplates[0].FuncMap = map[string]any{"mutate": func() string { + mutate() + return "first" + }} + } else { + first.FinalizeFunc = func(_ string) error { + mutate() + return nil + } + } + registry := testRegistry("test", func() coreGenerator { + return coreGenerator{name: "files", Generate: func(_ *Plan) ([]*codegen.File, error) { + return []*codegen.File{first}, nil + }} + }) + + _, err := generate(dir, "test", false, registry) + require.ErrorContains(t, err, "generated file renders mutated prepared design") + }) + } +} diff --git a/codegen/generator/generators.go b/codegen/generator/generators.go index e949d35391..76fda65c73 100644 --- a/codegen/generator/generators.go +++ b/codegen/generator/generators.go @@ -1,30 +1,155 @@ +// This file lists the generators used by each command. Every command run gets +// new functions, and both functions receive the same Plan. package generator import ( "fmt" + "reflect" "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" ) -// Genfunc is the type of the functions invoked to generate code. -type Genfunc func(genpkg string, roots []eval.Root) ([]*codegen.File, error) +type ( + // Genfunc is the released signature of a standalone generator function. + // The current generator uses the run-wide Plan instead. + // + // Deprecated: Register a PluginFactory to add generated files. + Genfunc func(genpkg string, roots []eval.Root) ([]*codegen.File, error) -// Generators returns the qualified paths (including the package name) to the -// code generator functions for the given command, an error if the command is -// not supported. Generators is a public variable so that external code (e.g. -// plugins) may override the default generators. + // coreGenerator chooses names and then builds one group of generated files. + coreGenerator struct { + // name identifies the file group in error messages. + name string + // Plan chooses generated names and saves the data needed to build files. + Plan func(*Plan) error + // Generate builds files from that same Plan after all names are final. + Generate func(*Plan) ([]*codegen.File, error) + } + + // generatorFactory returns a new pair of generator functions when called. + generatorFactory func() coreGenerator +) + +// Generators returns the generator functions for command. Plugins may replace +// it to add, remove, or reorder generators. var Generators = generators -// generators returns the generator functions exposed by the generator package -// for the given command. -func generators(cmd string) ([]Genfunc, error) { - switch cmd { +// generators returns Goa's built-in generator functions for command. +func generators(command string) ([]Genfunc, error) { + switch command { case "gen": return []Genfunc{Service, Transport, OpenAPI}, nil case "example": return []Genfunc{Example}, nil default: - return nil, fmt.Errorf("unknown command %q", cmd) + return nil, fmt.Errorf("unknown command %q", command) + } +} + +// generatorFactories returns fresh shared-plan adapters for the released +// generator list. Goa's built-in functions plan together. Additional plugin +// functions receive the prepared roots after every generated name is fixed. +func generatorFactories(command string) ([]generatorFactory, error) { + generate, err := Generators(command) + if err != nil { + return nil, err + } + factories := make([]generatorFactory, len(generate)) + for index, generator := range generate { + if generator == nil { + return nil, fmt.Errorf("generator %d for command %q is nil", index, command) + } + factories[index] = generatorFactoryFor(generator) + } + return factories, nil +} + +// generatorFactoryFor keeps built-in generators in the shared planning pass +// and adapts other released generator functions to the final rendering pass. +func generatorFactoryFor(generate Genfunc) generatorFactory { + pointer := reflect.ValueOf(generate).Pointer() + switch pointer { + case reflect.ValueOf(Service).Pointer(): + return serviceGeneratorFactory + case reflect.ValueOf(Transport).Pointer(): + return transportGeneratorFactory + case reflect.ValueOf(OpenAPI).Pointer(): + return openAPIGeneratorFactory + case reflect.ValueOf(Example).Pointer(): + return exampleGeneratorFactory + default: + return func() coreGenerator { + return coreGenerator{ + name: "external generator", + Generate: func(plan *Plan) ([]*codegen.File, error) { + generation := plan.Generation() + return generate(generation.GenPkg(), generation.Roots()) + }, + } + } + } +} + +// runStandaloneGenerator runs one released built-in function with a complete +// plan. It does not run registered plugins because callers invoked the core +// generator directly. +func runStandaloneGenerator(genpkg string, roots []eval.Root, factory generatorFactory) ([]*codegen.File, error) { + run := generationRun{cores: []coreGenerator{factory()}} + result, err := run.execute(genpkg, roots) + if err != nil { + return nil, err + } + return result.files, nil +} + +// genGeneratorFactories returns the service, transport, and OpenAPI generators +// used by the gen command. +func genGeneratorFactories() []generatorFactory { + return []generatorFactory{ + serviceGeneratorFactory, + transportGeneratorFactory, + openAPIGeneratorFactory, + } +} + +// exampleGeneratorFactories returns the generator used by the example command. +func exampleGeneratorFactories() []generatorFactory { + return []generatorFactory{exampleGeneratorFactory} +} + +// serviceGeneratorFactory returns a fresh service generator for one run. +func serviceGeneratorFactory() coreGenerator { + return coreGenerator{ + name: "service", + Plan: planServiceData, + Generate: serviceFiles, + } +} + +// transportGeneratorFactory returns a fresh transport generator for one run. +func transportGeneratorFactory() coreGenerator { + return coreGenerator{ + name: "transport", + Plan: planTransportData, + Generate: transportFiles, + } +} + +// openAPIGeneratorFactory returns a fresh OpenAPI generator for one run. +func openAPIGeneratorFactory() coreGenerator { + return coreGenerator{ + name: "openapi", + Plan: planOpenAPIData, + Generate: openAPIFiles, + } +} + +// exampleGeneratorFactory returns a fresh example generator for one run. +func exampleGeneratorFactory() coreGenerator { + return coreGenerator{ + name: "example", + Plan: planExampleData, + Generate: exampleFiles, } } diff --git a/codegen/generator/http_plan_test.go b/codegen/generator/http_plan_test.go new file mode 100644 index 0000000000..bef382bf10 --- /dev/null +++ b/codegen/generator/http_plan_test.go @@ -0,0 +1,33 @@ +// This file checks that plugins can find only the ordinary HTTP plan belonging +// to the exact prepared design root they received. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +func TestHTTPReturnsOnlyExactOrdinaryPlan(t *testing.T) { + root := &expr.RootExpr{} + sameContents := &expr.RootExpr{} + httpPlan := &httpcodegen.Plan{} + jsonrpcPlan := &httpcodegen.Plan{} + plan := &Plan{ + http: map[*expr.RootExpr]*httpcodegen.Plan{root: httpPlan}, + jsonrpcHTTP: map[*expr.RootExpr]*httpcodegen.Plan{sameContents: jsonrpcPlan}, + } + + got, ok := plan.HTTP(root) + require.True(t, ok) + require.Same(t, httpPlan, got) + got, ok = plan.HTTP(sameContents) + require.False(t, ok) + require.Nil(t, got) + got, ok = plan.HTTP(&expr.RootExpr{}) + require.False(t, ok) + require.Nil(t, got) +} diff --git a/codegen/generator/http_sse_retry_integration_test.go b/codegen/generator/http_sse_retry_integration_test.go new file mode 100644 index 0000000000..e6000ae1a3 --- /dev/null +++ b/codegen/generator/http_sse_retry_integration_test.go @@ -0,0 +1,120 @@ +// This file checks retry values in complete generated HTTP SSE transports. +// The server writes the designed integer field and the client rebuilds the +// service result or returns the exact parsing error for invalid event text. +package generator + +import ( + "testing" + + "goa.design/goa/v3/dsl" +) + +// httpSSERetryContractTest runs against the temporary generated module. +const httpSSERetryContractTest = `package integration + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_retry" + client "generated.local/gen/http/sse_retry/client" + server "generated.local/gen/http/sse_retry/server" + goahttp "goa.design/goa/v3/http" +) + +// retryService sends one event with both fields selected. +type retryService struct{} + +func (*retryService) Watch(_ context.Context, stream service.WatchServerStream) error { + data := "null" + retry := 2500 + return stream.Send(&service.Event{Data: &data, Retry: &retry}) +} + +func TestRetryRoundTrip(t *testing.T) { + handler := server.NewWatchHandler( + service.NewWatchEndpoint(&retryService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { t.Errorf("serve SSE: %v", err) }, + nil, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + stream := openWatch(t, httpServer.URL, http.DefaultClient) + event, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, event.Data) + require.Equal(t, "null", *event.Data) + require.NotNil(t, event.Retry) + require.Equal(t, 2500, *event.Retry) +} + +func TestMalformedRetryReturnsNumberError(t *testing.T) { + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, err := io.WriteString(w, "retry: later\ndata: ready\n\n") + require.NoError(t, err) + })) + defer httpServer.Close() + + stream := openWatch(t, httpServer.URL, http.DefaultClient) + _, err := stream.Recv() + var numberError *strconv.NumError + require.ErrorAs(t, err, &numberError) + require.Equal(t, "later", numberError.Num) +} + +// openWatch starts the generated client stream against url. +func openWatch(t *testing.T, url string, doer goahttp.Doer) client.WatchClientStream { + t.Helper() + transport := client.NewClient( + "http", + strings.TrimPrefix(url, "http://"), + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + false, + ) + raw, err := transport.Watch()(context.Background(), nil) + require.NoError(t, err) + return raw.(client.WatchClientStream) +} +` + +// TestGeneratedHTTPSSERetryParsing checks valid and invalid retry values using +// a generated server and client rather than template fragments. +func TestGeneratedHTTPSSERetryParsing(t *testing.T) { + dir := generateViewedTransportModule(t, httpSSERetryDSL) + writeGeneratedContractTest(t, dir, ".", httpSSERetryContractTest) + runGeneratedPackageTests(t, dir, ".") +} + +// httpSSERetryDSL defines one event with optional data and retry fields. The +// generated service uses pointers so nil and selected values remain distinct. +func httpSSERetryDSL() { + event := dsl.Type("Event", func() { + dsl.Attribute("data", dsl.String) + dsl.Attribute("retry", dsl.Int) + }) + dsl.Service("SSE Retry", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("data", func() { + dsl.SSEEventRetry("retry") + }) + }) + }) + }) +} diff --git a/codegen/generator/lifecycle.go b/codegen/generator/lifecycle.go new file mode 100644 index 0000000000..1ac8724307 --- /dev/null +++ b/codegen/generator/lifecycle.go @@ -0,0 +1,153 @@ +// This file runs plugin preparation, name selection, file planning, and file +// writing in one order shared by production generation and tests. +package generator + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +type ( + // generationRun stores the new core generators and plugins used by one run. + generationRun struct { + cores []coreGenerator + plugins []runPlugin + } + + // runPlugin stores one plugin's registered name and its Prepare, Plan, and + // Generate functions for this run. + runPlugin struct { + name string + Plugin + } + + // generationResult stores the generation state and files produced by one + // run. + generationResult struct { + plan *Plan + files []*codegen.File + } +) + +// executeGeneration creates new core generators and plugins, prepares the +// designs, chooses all names, and reports whether generation succeeded. +func executeGeneration(genpkg string, roots []eval.Root, command string, registry *registry) error { + run, err := newGenerationRun(command, registry) + if err != nil { + return err + } + _, err = run.execute(genpkg, roots) + return err +} + +// newGenerationRun copies the registered factories and calls each one once. +func newGenerationRun(command string, registry *registry) (*generationRun, error) { + coreFactories, pluginDescriptors, err := registry.snapshot(command) + if err != nil { + return nil, err + } + cores := make([]coreGenerator, len(coreFactories)) + for i, factory := range coreFactories { + cores[i] = factory() + } + plugins := make([]runPlugin, len(pluginDescriptors)) + for i, descriptor := range pluginDescriptors { + plugins[i] = runPlugin{name: descriptor.name, Plugin: descriptor.factory()} + } + return &generationRun{cores: cores, plugins: plugins}, nil +} + +// execute prepares the supplied designs, chooses names, and builds files. +func (r *generationRun) execute(genpkg string, roots []eval.Root) (*generationResult, error) { + for _, plugin := range r.plugins { + if plugin.Prepare != nil { + if err := plugin.Prepare(genpkg, roots); err != nil { + return nil, err + } + } + } + generation, err := codegen.NewGeneration(genpkg, roots) + if err != nil { + return nil, err + } + design, err := snapshotPreparedDesign(roots) + if err != nil { + return nil, err + } + plan := &Plan{ + generation: generation, + preparedRoots: roots, + examples: newExampleGenerators(roots), + design: design, + } + if err := plan.verifyPreparedDesign("example generator creation"); err != nil { + return nil, err + } + for _, core := range r.cores { + if core.Plan != nil { + callbackErr := core.Plan(plan) + if err := plan.verifyPreparedDesign(fmt.Sprintf("core %q plan", core.name)); err != nil { + return nil, err + } + if callbackErr != nil { + return nil, callbackErr + } + } + } + for _, plugin := range r.plugins { + if plugin.Plan != nil { + callbackErr := plugin.Plan(plan) + if err := plan.verifyPreparedDesign(fmt.Sprintf("plugin %q plan", plugin.name)); err != nil { + return nil, err + } + if callbackErr != nil { + return nil, callbackErr + } + } + } + freezeErr := plan.Generation().Freeze() + if err := plan.verifyPreparedDesign("generation freeze"); err != nil { + return nil, err + } + if freezeErr != nil { + return nil, freezeErr + } + linkErr := plan.link() + if err := plan.verifyPreparedDesign("plan linking"); err != nil { + return nil, err + } + if linkErr != nil { + return nil, linkErr + } + + var files []*codegen.File + for _, core := range r.cores { + if core.Generate == nil { + continue + } + generated, callbackErr := core.Generate(plan) + if err := plan.verifyPreparedDesign(fmt.Sprintf("core %q generate", core.name)); err != nil { + return nil, err + } + if callbackErr != nil { + return nil, callbackErr + } + files = append(files, generated...) + } + for _, plugin := range r.plugins { + if plugin.Generate == nil { + continue + } + generated, callbackErr := plugin.Generate(plan, files) + if err := plan.verifyPreparedDesign(fmt.Sprintf("plugin %q generate", plugin.name)); err != nil { + return nil, err + } + if callbackErr != nil { + return nil, callbackErr + } + files = generated + } + return &generationResult{plan: plan, files: files}, nil +} diff --git a/codegen/generator/openapi.go b/codegen/generator/openapi.go index 0381d2231a..5cb3538aad 100644 --- a/codegen/generator/openapi.go +++ b/codegen/generator/openapi.go @@ -1,20 +1,45 @@ +// This file builds each requested OpenAPI document while the evaluated design +// is available. File generation later returns the documents already built. package generator import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// OpenAPI iterates through the roots and returns the files needed to render -// the service OpenAPI spec. It produces OpenAPI specifications only if the -// roots define a HTTP service. -func OpenAPI(_ string, roots []eval.Root) ([]*codegen.File, error) { - for _, root := range roots { - if r, ok := root.(*expr.RootExpr); ok { - return httpcodegen.OpenAPIFiles(r) +// OpenAPI returns the OpenAPI documents for roots. +func OpenAPI(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + return runStandaloneGenerator(genpkg, roots, openAPIGeneratorFactory) +} + +// openAPIFiles returns the OpenAPI files built during planning. +func openAPIFiles(plan *Plan) ([]*codegen.File, error) { + if len(plan.openapiReplacements) > 0 { + var files []*codegen.File + for _, openapi := range plan.openapiReplacements { + files = append(files, openapi.Files()...) } + return files, nil + } + return plan.openapi.Files(), nil +} + +// planOpenAPIData builds the OpenAPI files for the application's design root. +// Later roots contain generated support services and do not describe another +// application API. +func planOpenAPIData(plan *Plan) error { + roots := serviceRoots(plan.Generation().Roots()) + if len(roots) == 0 { + plan.openapi = new(httpcodegen.OpenAPIPlan) + plan.openapiRoot = nil + return nil + } + openapi, err := httpcodegen.NewOpenAPIPlan(roots[0], plan.exampleGenerator(roots[0])) + if err != nil { + return err } - return nil, nil + plan.openapi = openapi + plan.openapiRoot = roots[0] + return nil } diff --git a/codegen/generator/openapi_replace_test.go b/codegen/generator/openapi_replace_test.go new file mode 100644 index 0000000000..812633a68e --- /dev/null +++ b/codegen/generator/openapi_replace_test.go @@ -0,0 +1,86 @@ +// This file verifies that a plugin can replace the OpenAPI documents for the +// exact application design before generation names become final. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + "goa.design/goa/v3/http/codegen/openapi" + httpdata "goa.design/goa/v3/http/codegen/testdata" +) + +func TestReplaceOpenAPI(t *testing.T) { + root := codegen.RunDSL(t, httpdata.SimpleDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan := &Plan{ + generation: generation, + preparedRoots: []eval.Root{root}, + examples: newExampleGenerators([]eval.Root{root}), + } + require.NoError(t, planOpenAPIData(plan)) + replacement, err := httpcodegen.NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + second, err := httpcodegen.NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + []openapi.Spec{{Version: openapi.Version20, Path: "localized/openapi"}}, + openapi.Values{}, + ) + require.NoError(t, err) + + require.NoError(t, plan.ReplaceOpenAPI(root, replacement, second)) + files, err := openAPIFiles(plan) + require.NoError(t, err) + require.Equal(t, append(replacement.Files(), second.Files()...), files) +} + +func TestReplaceOpenAPIRejectsInvalidOwnerPhaseAndPlans(t *testing.T) { + root := codegen.RunDSL(t, httpdata.SimpleDSL) + other := codegen.RunDSL(t, httpdata.SimpleDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan := &Plan{ + generation: generation, + preparedRoots: []eval.Root{root}, + examples: newExampleGenerators([]eval.Root{root}), + } + require.NoError(t, planOpenAPIData(plan)) + replacement, err := httpcodegen.NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + original, err := openAPIFiles(plan) + require.NoError(t, err) + + require.ErrorContains(t, plan.ReplaceOpenAPI(nil, replacement), "root is nil") + require.ErrorContains(t, plan.ReplaceOpenAPI(other, replacement), "not the application design root") + require.ErrorContains(t, plan.ReplaceOpenAPI(root), "at least one") + require.ErrorContains(t, plan.ReplaceOpenAPI(root, nil), "plan 0 is nil") + require.ErrorContains(t, plan.ReplaceOpenAPI(root, replacement, replacement), "same output path") + upper, err := httpcodegen.NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + []openapi.Spec{{Version: openapi.Version20, Path: "docs/API"}}, + openapi.Values{}, + ) + require.NoError(t, err) + lower, err := httpcodegen.NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + []openapi.Spec{{Version: openapi.Version30, Path: "docs/api"}}, + openapi.Values{}, + ) + require.NoError(t, err) + require.ErrorContains(t, plan.ReplaceOpenAPI(root, upper, lower), "case-insensitive filesystem") + unchanged, err := openAPIFiles(plan) + require.NoError(t, err) + require.Equal(t, original, unchanged) + + require.NoError(t, generation.Freeze()) + require.ErrorContains(t, plan.ReplaceOpenAPI(root, replacement), "after generation freeze") +} diff --git a/codegen/generator/plan.go b/codegen/generator/plan.go new file mode 100644 index 0000000000..61844ad383 --- /dev/null +++ b/codegen/generator/plan.go @@ -0,0 +1,216 @@ +// This file stores the input designs, chosen Go names, and output files for one +// run. Built-in file writers and plugins read the same values. +package generator + +import ( + "fmt" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + grpccodegen "goa.design/goa/v3/grpc/codegen" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" +) + +type ( + // Plan holds the input designs, chosen Go names, and generated files for one + // run. Code that writes files receives it after all Go names are known. + Plan struct { + generation *codegen.Generation + preparedRoots []eval.Root + examples map[*expr.RootExpr]*expr.ExampleGenerator + example []*examplePlanEntry + openapi *httpcodegen.OpenAPIPlan + openapiRoot *expr.RootExpr + openapiReplacements []*httpcodegen.OpenAPIPlan + services map[*expr.RootExpr]*service.Plan + serviceOrder []*service.Plan + http map[*expr.RootExpr]*httpcodegen.Plan + jsonrpcHTTP map[*expr.RootExpr]*httpcodegen.Plan + jsonrpc map[*expr.RootExpr]*jsonrpccodegen.Plan + grpc map[*expr.RootExpr]*grpccodegen.Plan + transports []*transportPlanEntry + transportDone bool + design *designSnapshot + } + + // transportPlanEntry keeps the transport plans for one service design in + // the order chosen during planning. + transportPlanEntry struct { + http *httpcodegen.Plan + jsonrpcHTTP *httpcodegen.Plan + jsonrpc *jsonrpccodegen.Plan + grpc *grpccodegen.Plan + } + + // examplePlanEntry keeps one copied example root with the plans that write + // files for that same design. + examplePlanEntry struct { + source *expr.RootExpr + root *example.Root + service *service.Plan + http *httpcodegen.ExamplePlan + jsonrpc *jsonrpccodegen.ExamplePlan + grpc *grpccodegen.ExamplePlan + } +) + +// Generation returns the names chosen for Go declarations and imports in this +// run. +func (p *Plan) Generation() *codegen.Generation { + return p.generation +} + +// Service returns the generated service data for root. It panics when root was +// not included in this run. +func (p *Plan) Service(root *expr.RootExpr) *service.Plan { + plan, ok := p.services[root] + if !ok { + panic(fmt.Sprintf("service plan requested for unplanned design root %q", root.API.Name)) + } + return plan +} + +// HTTP returns the ordinary HTTP plan created for root. It returns false for a +// different root value and for designs exposed only through JSON-RPC. +func (p *Plan) HTTP(root *expr.RootExpr) (*httpcodegen.Plan, bool) { + plan, ok := p.http[root] + return plan, ok +} + +// GRPC returns the gRPC plan created for the exact design root. It returns +// false when the root was not included in gRPC planning. +func (p *Plan) GRPC(root *expr.RootExpr) (*grpccodegen.Plan, bool) { + plan, ok := p.grpc[root] + return plan, ok +} + +// JSONRPC returns the JSON-RPC plan created for the exact design root. It +// returns false when the root was not included in JSON-RPC planning. +func (p *Plan) JSONRPC(root *expr.RootExpr) (*jsonrpccodegen.Plan, bool) { + plan, ok := p.jsonrpc[root] + return plan, ok +} + +// Example returns a separate copy of the example server description created +// for the exact design root. It returns false when the root was not included +// in example planning. +func (p *Plan) Example(root *expr.RootExpr) (*example.Root, bool) { + for _, entry := range p.example { + if entry.source == root { + return copyExampleRoot(entry.root), true + } + } + return nil, false +} + +// ReplaceOpenAPI replaces the OpenAPI documents for the application root with +// files already built by plans. It must be called during plugin planning, +// before generated names become final. +func (p *Plan) ReplaceOpenAPI(root *expr.RootExpr, plans ...*httpcodegen.OpenAPIPlan) error { + if root == nil { + return fmt.Errorf("OpenAPI replacement root is nil") + } + if root != p.openapiRoot { + return fmt.Errorf("root %q is not the application design root", root.API.Name) + } + if p.generation.Frozen() { + return fmt.Errorf("OpenAPI documents cannot be replaced after generation freeze") + } + if len(plans) == 0 { + return fmt.Errorf("OpenAPI replacement requires at least one plan") + } + for index, plan := range plans { + if plan == nil { + return fmt.Errorf("OpenAPI replacement plan %d is nil", index) + } + } + if err := validateOpenAPIPlanPaths(plans); err != nil { + return err + } + p.openapiReplacements = append([]*httpcodegen.OpenAPIPlan(nil), plans...) + return nil +} + +// exampleGenerator returns the example values created for root. It panics when +// root was not included in this run. +func (p *Plan) exampleGenerator(root *expr.RootExpr) *expr.ExampleGenerator { + generator, ok := p.examples[root] + if !ok { + panic(fmt.Sprintf("example generator requested for unplanned design root %q", root.API.Name)) + } + return generator +} + +// link completes each service and then builds the protocol files that use it. +func (p *Plan) link() error { + for _, plan := range p.serviceOrder { + if err := plan.Link(); err != nil { + return err + } + } + for _, transport := range p.transports { + if plan := transport.http; plan != nil { + if err := plan.Link(); err != nil { + return err + } + } + if plan := transport.jsonrpcHTTP; plan != nil { + if err := plan.Link(); err != nil { + return err + } + } + if plan := transport.jsonrpc; plan != nil { + if err := plan.Link(); err != nil { + return err + } + } + if plan := transport.grpc; plan != nil { + if err := plan.Link(); err != nil { + return err + } + } + } + return nil +} + +// verifyPreparedDesign reports the first service design value changed after +// planning and names the code that changed it. +func (p *Plan) verifyPreparedDesign(operation string) error { + path, err := p.design.changedPath(p.preparedRoots) + if err != nil { + return fmt.Errorf("%s left prepared design unverifiable: %w", operation, err) + } + if path != "" { + return fmt.Errorf("%s mutated prepared design at %s", operation, path) + } + return nil +} + +// validateOpenAPIPlanPaths rejects two OpenAPI files that use the same path, +// including paths that differ only by letter case. +func validateOpenAPIPlanPaths(plans []*httpcodegen.OpenAPIPlan) error { + var paths []string + for _, plan := range plans { + for _, file := range plan.Files() { + for _, existing := range paths { + if existing == file.Path { + return fmt.Errorf("OpenAPI plans use the same output path %q", file.Path) + } + if strings.EqualFold(existing, file.Path) { + return fmt.Errorf( + "OpenAPI paths %q and %q collide on a case-insensitive filesystem", + existing, + file.Path, + ) + } + } + paths = append(paths, file.Path) + } + } + return nil +} diff --git a/codegen/generator/plugin.go b/codegen/generator/plugin.go new file mode 100644 index 0000000000..0eac52f595 --- /dev/null +++ b/codegen/generator/plugin.go @@ -0,0 +1,239 @@ +// This file stores the generator functions registered for each command and +// creates a separate plugin value for each run. Registration closes when +// generation first starts. +package generator + +import ( + "fmt" + "slices" + "strings" + "sync" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/internal/pluginregistry" +) + +type ( + // Plugin contains the optional functions run for one new plugin instance. + // Plan and Generate receive the same Plan pointer. + Plugin struct { + // Prepare may change designs before Goa records their prepared values. + Prepare codegen.PrepareFunc + // Plan adds the plugin's package-level names before all names are final. + Plan func(*Plan) error + // Generate adds or changes files after all names are final. + Generate func(*Plan, []*codegen.File) ([]*codegen.File, error) + } + + // PluginFactory creates one independent plugin instance for each run. + PluginFactory func() Plugin + + // registry stores the core and plugin factories used by each command. + registry struct { + mu sync.Mutex + commands map[string][]generatorFactory + commandGenerators func(string) ([]generatorFactory, error) + plugins []pluginDescriptor + registeredPlugins func() []registeredPluginDescriptor + sealed bool + } + + // pluginDescriptor stores one plugin registration. + pluginDescriptor struct { + name string + command string + position pluginPosition + factory PluginFactory + } + + // registeredPluginDescriptor copies one plugin registered through the + // released Goa v3 API before adapting it to a per-run plugin. + registeredPluginDescriptor struct { + name string + command string + position pluginPosition + prepare codegen.PrepareFunc + generate codegen.GenerateFunc + } + + // pluginPosition defines the three plugin ordering groups. + pluginPosition uint8 +) + +const ( + pluginFirst pluginPosition = iota + pluginNormal + pluginLast +) + +var defaultRegistry = newDefaultRegistry() + +// RegisterPlugin registers a factory in the normal alphabetically ordered +// group. It panics when name is empty, command is unknown, factory is nil, the +// command already has a plugin with name, or generation has already started. +func RegisterPlugin(name, command string, factory PluginFactory) { + defaultRegistry.registerPlugin(name, command, pluginNormal, factory) +} + +// RegisterPluginFirst registers a factory before normal and Last plugins. It +// enforces the same registration contract as RegisterPlugin. +func RegisterPluginFirst(name, command string, factory PluginFactory) { + defaultRegistry.registerPlugin(name, command, pluginFirst, factory) +} + +// RegisterPluginLast registers a factory after First and normal plugins. It +// enforces the same registration contract as RegisterPlugin. +func RegisterPluginLast(name, command string, factory PluginFactory) { + defaultRegistry.registerPlugin(name, command, pluginLast, factory) +} + +// newRegistry creates an empty list of commands and plugins for setup or tests. +func newRegistry() *registry { + return ®istry{commands: make(map[string][]generatorFactory)} +} + +// newDefaultRegistry creates the production command registry before external +// package initialization registers plugins. +func newDefaultRegistry() *registry { + registry := newRegistry() + registry.commands["gen"] = genGeneratorFactories() + registry.commands["example"] = exampleGeneratorFactories() + registry.commandGenerators = generatorFactories + registry.registeredPlugins = snapshotRegisteredPlugins + return registry +} + +// addCommand adds core generators to a command used by a test. +func (r *registry) addCommand(command string, factories ...generatorFactory) { + r.mu.Lock() + defer r.mu.Unlock() + if r.sealed { + panic("generator registry is sealed") + } + r.commands[command] = slices.Clone(factories) +} + +// registerPlugin adds one named factory to a known command before generation +// starts. A command cannot contain two plugins with the same name. +func (r *registry) registerPlugin(name, command string, position pluginPosition, factory PluginFactory) { + if factory == nil { + panic("plugin factory is nil") + } + r.mu.Lock() + defer r.mu.Unlock() + if r.sealed { + panic("generator plugin registry is sealed") + } + if name == "" { + panic("plugin name is empty") + } + if _, ok := r.commands[command]; !ok { + panic(fmt.Sprintf("unknown generator command %q", command)) + } + for _, plugin := range r.plugins { + if plugin.command == command && plugin.name == name { + panic(fmt.Sprintf("plugin %q is already registered for command %q", name, command)) + } + } + r.plugins = append(r.plugins, pluginDescriptor{ + name: name, + command: command, + position: position, + factory: factory, + }) +} + +// snapshot closes registration and returns copied factories in a repeatable order. +func (r *registry) snapshot(command string) ([]generatorFactory, []pluginDescriptor, error) { + var ( + selectedFactories []generatorFactory + hasSelection bool + ) + if r.commandGenerators != nil { + var err error + selectedFactories, err = r.commandGenerators(command) + if err != nil { + return nil, nil, err + } + hasSelection = true + } + r.mu.Lock() + defer r.mu.Unlock() + factories, ok := r.commands[command] + if hasSelection { + factories = selectedFactories + ok = true + } + if !ok { + return nil, nil, fmt.Errorf("unknown command %q", command) + } + r.sealed = true + selected := make([]pluginDescriptor, 0, len(r.plugins)) + factoryNames := make(map[string]struct{}, len(r.plugins)) + for _, plugin := range r.plugins { + if plugin.command != command { + continue + } + selected = append(selected, plugin) + factoryNames[plugin.name] = struct{}{} + } + if r.registeredPlugins != nil { + for _, registered := range r.registeredPlugins() { + if registered.command != command { + continue + } + if _, ok := factoryNames[registered.name]; ok { + return nil, nil, fmt.Errorf("plugin %q is already registered for command %q", registered.name, command) + } + selected = append(selected, registered.pluginDescriptor()) + } + } + slices.SortStableFunc(selected, func(left, right pluginDescriptor) int { + if left.position != right.position { + return int(left.position) - int(right.position) + } + return strings.Compare(left.name, right.name) + }) + return slices.Clone(factories), selected, nil +} + +// snapshotRegisteredPlugins stops further callback registration and copies +// each registered callback into the list used by this generation run. +func snapshotRegisteredPlugins() []registeredPluginDescriptor { + plugins := pluginregistry.Snapshot[codegen.PrepareFunc, codegen.GenerateFunc]() + descriptors := make([]registeredPluginDescriptor, len(plugins)) + for index, plugin := range plugins { + position := pluginNormal + if plugin.Position == pluginregistry.First { + position = pluginFirst + } else if plugin.Position == pluginregistry.Last { + position = pluginLast + } + descriptors[index] = registeredPluginDescriptor{ + name: plugin.Name, + command: plugin.Command, + position: position, + prepare: plugin.Prepare, + generate: plugin.Generate, + } + } + return descriptors +} + +// pluginDescriptor adapts a released callback pair to the same factory and +// per-run Plan used by newer plugins. +func (p registeredPluginDescriptor) pluginDescriptor() pluginDescriptor { + return pluginDescriptor{ + name: p.name, + command: p.command, + position: p.position, + factory: func() Plugin { + return Plugin{ + Prepare: p.prepare, + Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + return p.generate(plan.Generation().GenPkg(), plan.preparedRoots, files) + }, + } + }, + } +} diff --git a/codegen/generator/plugin_public_integration_test.go b/codegen/generator/plugin_public_integration_test.go new file mode 100644 index 0000000000..751d1fce3a --- /dev/null +++ b/codegen/generator/plugin_public_integration_test.go @@ -0,0 +1,380 @@ +// This file runs the public plugin registration APIs in fresh child processes. +// Each child uses the real default registry, so the tests cover rejecting late +// registrations and running released callbacks across generation commands. +package generator + +import ( + "cmp" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + servicecodegen "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +type ( + // publicHTTPPluginOrder gives declarations in the compile test a stable + // order within the generated server package. + publicHTTPPluginOrder string + + // publicHTTPPluginData supplies three generated names to the plugin source + // template after Goa has chosen all generated names. + publicHTTPPluginData struct { + // Wrapper is the handler wrapper name chosen with core server names. + Wrapper *codegen.NameDeclaration + // EndpointWrapper is the private wrapper chosen for the Read endpoint. + EndpointWrapper *codegen.NameDeclaration + // Mount is the extra mount function name chosen with core server names. + Mount *codegen.NameDeclaration + } +) + +const publicPluginChildMode = "GOA_PUBLIC_PLUGIN_CHILD" + +// TestPublicPluginRegistrationUsesDefaultGenerationRun verifies released and +// factory registrations using fresh package globals in separate processes. +func TestPublicPluginRegistrationUsesDefaultGenerationRun(t *testing.T) { + switch os.Getenv(publicPluginChildMode) { + case "run": + runPublicPluginChild(t) + return + case "duplicate": + runPublicPluginDuplicateChild(t) + return + case "repeat": + runPublicPluginRepeatedRunChild(t) + return + case "http-extension": + runPublicHTTPServerExtensionChild(t) + return + case "legacy-http-endpoint": + runPublicLegacyHTTPEndpointChild(t) + return + } + + for _, mode := range []string{"run", "duplicate", "repeat", "http-extension", "legacy-http-endpoint"} { + t.Run(mode, func(t *testing.T) { + command := exec.Command(os.Args[0], "-test.run=^TestPublicPluginRegistrationUsesDefaultGenerationRun$") + command.Env = append(os.Environ(), publicPluginChildMode+"="+mode) + output, err := command.CombinedOutput() + require.NoErrorf(t, err, "child process failed:\n%s", output) + }) + } +} + +// runPublicLegacyHTTPEndpointChild checks that a released plugin can add an +// endpoint using the public handler name without knowing Goa's private plan. +func runPublicLegacyHTTPEndpointChild(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { + dsl.GET("/items") + }) + }) + }) + }) + codegen.RegisterPlugin("legacy-http-endpoint", "gen", nil, func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + for _, file := range files { + if file.Path != filepath.Join(codegen.Gendir, "http", "calc", "server", "server.go") { + continue + } + for _, section := range file.SectionTemplates { + if section.Name != "server-init" { + continue + } + data := section.Data.(*httpcodegen.ServiceData) + data.Endpoints = append(data.Endpoints, &httpcodegen.EndpointData{ + Method: &servicecodegen.MethodData{VarName: "CORS"}, + MountHandler: "MountCORSHandler", + HandlerInit: "NewCORSHandler", + }) + section.Source = strings.ReplaceAll( + section.Source, + `e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitName }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }})`, + `{{ if ne .Method.VarName "CORS" }}e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitName }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }}{{ end }})`, + ) + } + } + return files, nil + }) + + run, err := newGenerationRun("gen", defaultRegistry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + for _, file := range result.files { + if file.Path != filepath.Join(codegen.Gendir, "http", "calc", "server", "server.go") { + continue + } + code := codegen.SectionsCode(t, file.Section("server-init")) + require.Contains(t, code, "CORS: NewCORSHandler()") + mount := codegen.SectionsCode(t, file.Section("server-mount")) + require.Contains(t, mount, "MountCORSHandler(mux, h.CORS)") + return + } + t.Fatal("generated HTTP server file is missing") +} + +// runPublicPluginRepeatedRunChild registers through the released API once and +// checks that the same functions receive each later run's package and root. +func runPublicPluginRepeatedRunChild(t *testing.T) { + var prepared, generated []string + codegen.RegisterPlugin( + "repeat", + "gen", + func(genpkg string, roots []eval.Root) error { + prepared = append(prepared, genpkg+":"+roots[0].(*expr.RootExpr).API.Name) + return nil + }, + func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + name := roots[0].(*expr.RootExpr).API.Name + generated = append(generated, genpkg+":"+name) + return append(files, &codegen.File{Path: "released-" + name}), nil + }, + ) + + packages := []string{"generated.local/first", "generated.local/second"} + for index, name := range []string{"first", "second"} { + root := expr.RunDSL(t, func() { + dsl.API(name, func() { + }) + }) + run, err := newGenerationRun("gen", defaultRegistry) + require.NoError(t, err) + result, err := run.execute(packages[index], []eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "released-"+name, result.files[len(result.files)-1].Path) + } + + require.Equal(t, []string{ + "generated.local/first:first", + "generated.local/second:second", + }, prepared) + require.Equal(t, prepared, generated) +} + +// runPublicHTTPServerExtensionChild generates and compiles an HTTP service with +// a public per-run plugin that defines both extension function bodies. +func runPublicHTTPServerExtensionChild(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("id", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/items/{id}") + }) + }) + }) + }) + RegisterPlugin("http-server-extension", "gen", func() Plugin { + data := &publicHTTPPluginData{} + return Plugin{ + Plan: func(plan *Plan) error { + httpPlan, ok := plan.HTTP(root) + if !ok { + return fmt.Errorf("ordinary HTTP plan is missing") + } + service := root.API.HTTP.Services[0] + var err error + data.Wrapper, err = httpPlan.DeclareServerHandlerWrapper(service, "WrapExtension", publicHTTPPluginOrder("wrapper")) + if err != nil { + return err + } + data.EndpointWrapper, err = httpPlan.DeclareServerEndpointHandlerWrapper(service.HTTPEndpoints[0], "wrapReadExtension", publicHTTPPluginOrder("endpoint wrapper")) + if err != nil { + return err + } + data.Mount, err = httpPlan.DeclareServerMount(service, "MountExtension", publicHTTPPluginOrder("mount"), []httpcodegen.ServerMountPoint{{ + Method: "Extension preflight", + Verb: "OPTIONS", + Pattern: "/items/{id}", + }}) + return err + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + return append(files, publicHTTPServerExtensionFile(data)), nil + }, + } + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + serverSource, err := os.ReadFile(filepath.Join(genDir, "http", "calc", "server", "server.go")) + require.NoError(t, err) + require.Contains(t, string(serverSource), "h = WrapExtension(wrapReadExtension(h))") + require.Contains(t, string(serverSource), "MountReadHandler(mux, h.Read)") + require.NotContains(t, string(serverSource), "MountReadHandler(mux, WrapExtension") + runGeneratedTests(t, genDir) +} + +// runPublicPluginChild mixes both public APIs and checks the arguments and +// files passed through the real default generation run. +func runPublicPluginChild(t *testing.T) { + root := expr.RunDSL(t, func() {}) + var events []string + registerPublicFactoryPlugin("a-first", pluginFirst, &events) + registerPublicReleasedPlugin("z-first", pluginFirst, root, &events) + registerPublicReleasedPlugin("a-normal", pluginNormal, root, &events) + registerPublicFactoryPlugin("z-normal", pluginNormal, &events) + registerPublicReleasedPlugin("a-last", pluginLast, root, &events) + registerPublicFactoryPlugin("z-last", pluginLast, &events) + + run, err := newGenerationRun("gen", defaultRegistry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.Equal(t, []string{ + "prepare:a-first", "prepare:z-first", "prepare:a-normal", "prepare:z-normal", "prepare:a-last", "prepare:z-last", + "generate:a-first", "generate:z-first:factory:a-first", "generate:a-normal:released:z-first", + "generate:z-normal:released:a-normal", "generate:a-last:factory:z-normal", "generate:z-last:released:a-last", + }, events) + require.Equal(t, "factory:z-last", result.files[len(result.files)-1].Path) + require.PanicsWithValue(t, "plugin registry is sealed", func() { + codegen.RegisterPlugin("late", "gen", nil, publicUnchangedFiles) + }) + require.PanicsWithValue(t, "generator plugin registry is sealed", func() { + RegisterPlugin("late", "gen", func() Plugin { + return Plugin{} + }) + }) +} + +// runPublicPluginDuplicateChild proves that registrations for another command +// do not block or run during this command. +func runPublicPluginDuplicateChild(t *testing.T) { + called := false + codegen.RegisterPlugin("duplicate", "example", nil, func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + called = true + return files, nil + }) + RegisterPlugin("duplicate", "example", func() Plugin { + called = true + return Plugin{} + }) + + _, err := newGenerationRun("gen", defaultRegistry) + require.NoError(t, err) + require.False(t, called) +} + +// registerPublicReleasedPlugin adds one old-style callback pair through the +// exact API used by released Goa v3 plugins. +func registerPublicReleasedPlugin(name string, position pluginPosition, root eval.Root, events *[]string) { + prepare := func(genpkg string, roots []eval.Root) error { + if genpkg != "generated.local/gen" { + return fmt.Errorf("prepare received package %q", genpkg) + } + if len(roots) != 1 || roots[0] != root { + return fmt.Errorf("prepare received another run's roots") + } + *events = append(*events, "prepare:"+name) + return nil + } + generate := func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + if genpkg != "generated.local/gen" { + return nil, fmt.Errorf("generate received package %q", genpkg) + } + if len(roots) != 1 || roots[0] != root { + return nil, fmt.Errorf("generate received another run's roots") + } + *events = append(*events, "generate:"+name+":"+files[len(files)-1].Path) + return append(files, &codegen.File{Path: "released:" + name}), nil + } + switch position { + case pluginFirst: + codegen.RegisterPluginFirst(name, "gen", prepare, generate) + case pluginNormal: + codegen.RegisterPlugin(name, "gen", prepare, generate) + case pluginLast: + codegen.RegisterPluginLast(name, "gen", prepare, generate) + } +} + +// registerPublicFactoryPlugin adds one planning-aware plugin through the new +// API and records the file left by the preceding plugin. +func registerPublicFactoryPlugin(name string, position pluginPosition, events *[]string) { + factory := func() Plugin { + return Plugin{ + Prepare: func(_ string, _ []eval.Root) error { + *events = append(*events, "prepare:"+name) + return nil + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + event := "generate:" + name + if len(files) > 0 { + event += ":" + files[len(files)-1].Path + } + *events = append(*events, event) + return append(files, &codegen.File{Path: "factory:" + name}), nil + }, + } + } + switch position { + case pluginFirst: + RegisterPluginFirst(name, "gen", factory) + case pluginNormal: + RegisterPlugin(name, "gen", factory) + case pluginLast: + RegisterPluginLast(name, "gen", factory) + } +} + +// publicUnchangedFiles is a valid callback used to test late registration. +func publicUnchangedFiles(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + return files, nil +} + +// publicHTTPServerExtensionFile writes the two functions promised during +// plugin planning into the generated Calc server package. +func publicHTTPServerExtensionFile(data *publicHTTPPluginData) *codegen.File { + return &codegen.File{ + Path: filepath.Join(codegen.Gendir, "http", "calc", "server", "plugin.go"), + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header("Calc HTTP server plugin", "server", []*codegen.ImportSpec{ + codegen.SimpleImport("net/http"), + codegen.GoaNamedImport("http", "goahttp"), + }), + { + Name: "http-server-extension", + Source: `// {{ .Wrapper.Name }} wraps a handler mounted from the Calc design. +func {{ .Wrapper.Name }}(handler http.Handler) http.Handler { + return handler +} + +// {{ .EndpointWrapper.Name }} wraps only the Read endpoint handler. +func {{ .EndpointWrapper.Name }}(handler http.Handler) http.Handler { + return handler +} + +// {{ .Mount.Name }} adds the Calc preflight route. +func {{ .Mount.Name }}(mux goahttp.Muxer) { + mux.Handle("OPTIONS", "/items/{id}", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) +}`, + Data: data, + }, + }, + } +} + +// ComparePackageName gives public plugin declarations a stable order. +func (o publicHTTPPluginOrder) ComparePackageName(other codegen.PackageNameOrder) int { + return cmp.Compare(string(o), string(other.(publicHTTPPluginOrder))) +} diff --git a/codegen/generator/plugin_registry_contract_test.go b/codegen/generator/plugin_registry_contract_test.go new file mode 100644 index 0000000000..66d94f1299 --- /dev/null +++ b/codegen/generator/plugin_registry_contract_test.go @@ -0,0 +1,90 @@ +// This file verifies that plugin registration rejects ambiguous ownership +// before a generation run seals and snapshots the command registry. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPluginRegistrationRejectsInvalidIdentity verifies that malformed plugin +// ownership is rejected before any generation run snapshots the registry. +func TestPluginRegistrationRejectsInvalidIdentity(t *testing.T) { + tests := []struct { + name string + plugin string + command string + }{ + {name: "empty plugin name", command: "test"}, + {name: "unknown command", plugin: "plugin", command: "missing"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := newRegistry() + registry.addCommand("test") + + require.Panics(t, func() { + registry.registerPlugin(test.plugin, test.command, pluginNormal, func() Plugin { + return Plugin{} + }) + }) + }) + } +} + +// TestPluginRegistrationRejectsDuplicateCommandName verifies that a plugin +// cannot acquire two ordering positions for the same command and owner name. +func TestPluginRegistrationRejectsDuplicateCommandName(t *testing.T) { + positions := []struct { + name string + position pluginPosition + }{ + {name: "first", position: pluginFirst}, + {name: "normal", position: pluginNormal}, + {name: "last", position: pluginLast}, + } + + for _, initial := range positions { + for _, duplicate := range positions { + t.Run(initial.name+" then "+duplicate.name, func(t *testing.T) { + registry := newRegistry() + registry.addCommand("test") + registry.registerPlugin("plugin", "test", initial.position, func() Plugin { + return Plugin{} + }) + + require.Panics(t, func() { + registry.registerPlugin("plugin", "test", duplicate.position, func() Plugin { + return Plugin{} + }) + }) + }) + } + } +} + +// TestPluginRegistrationScopesNamesToCommand verifies that two commands may +// use the same owner name because each command snapshots its own plugin list. +func TestPluginRegistrationScopesNamesToCommand(t *testing.T) { + registry := newRegistry() + registry.addCommand("first") + registry.addCommand("second") + registry.registerPlugin("plugin", "first", pluginNormal, func() Plugin { + return Plugin{} + }) + registry.registerPlugin("plugin", "second", pluginNormal, func() Plugin { + return Plugin{} + }) + + _, first, err := registry.snapshot("first") + require.NoError(t, err) + require.Len(t, first, 1) + require.Equal(t, "first", first[0].command) + + _, second, err := registry.snapshot("second") + require.NoError(t, err) + require.Len(t, second, 1) + require.Equal(t, "second", second[0].command) +} diff --git a/codegen/generator/plugin_test.go b/codegen/generator/plugin_test.go new file mode 100644 index 0000000000..4300e73b27 --- /dev/null +++ b/codegen/generator/plugin_test.go @@ -0,0 +1,754 @@ +// This file verifies that generator and plugin factories create isolated run +// objects and that every phase receives one retained Plan in stable order. +package generator + +import ( + "errors" + "fmt" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + httpdata "goa.design/goa/v3/http/codegen/testdata" +) + +// TestPluginFactoryOrderAndPlan verifies First, normal, and Last ordering and +// proves that plugin planning and rendering receive the exact same Plan pointer. +func TestPluginFactoryOrderAndPlan(t *testing.T) { + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{} + }) + var ( + events []string + plans []*Plan + planMux sync.Mutex + ) + register := func(position pluginPosition, name string) { + registry.registerPlugin(name, "test", position, func() Plugin { + return Plugin{ + Prepare: func(_ string, _ []eval.Root) error { + events = append(events, "prepare:"+name) + return nil + }, + Plan: func(plan *Plan) error { + events = append(events, "plan:"+name) + planMux.Lock() + plans = append(plans, plan) + planMux.Unlock() + return nil + }, + Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + events = append(events, "generate:"+name) + planMux.Lock() + plans = append(plans, plan) + planMux.Unlock() + return files, nil + }, + } + }) + } + register(pluginLast, "z-last") + register(pluginNormal, "z-normal") + register(pluginFirst, "b-first") + register(pluginFirst, "a-first") + register(pluginNormal, "a-normal") + register(pluginLast, "a-last") + + err := executeGeneration("generated.local/gen", nil, "test", registry) + require.NoError(t, err) + require.Equal(t, []string{ + "prepare:a-first", "prepare:b-first", "prepare:a-normal", "prepare:z-normal", "prepare:a-last", "prepare:z-last", + "plan:a-first", "plan:b-first", "plan:a-normal", "plan:z-normal", "plan:a-last", "plan:z-last", + "generate:a-first", "generate:b-first", "generate:a-normal", "generate:z-normal", "generate:a-last", "generate:z-last", + }, events) + require.Len(t, plans, 12) + for _, plan := range plans[1:] { + require.Same(t, plans[0], plan) + } + require.NotNil(t, plans[0].Generation()) +} + +// TestPluginPlannedHTTPDataIsAccepted checks that a factory plugin may declare +// a constructor during Plan and use it as direct HTTP data during Generate. +func TestPluginPlannedHTTPDataIsAccepted(t *testing.T) { + root := codegen.RunDSL(t, httpdata.ServerSimpleRoutingDSL) + registry := newDefaultRegistry() + registry.registerPlugin("planned-http-data", "gen", pluginNormal, func() Plugin { + var declaration *codegen.NameDeclaration + return Plugin{ + Plan: func(plan *Plan) error { + pkg, err := plan.Generation().ClaimPackage("generated.local/gen/http/plugin") + if err != nil { + return err + } + declaration = codegen.NewExactName(codegen.NameFunction, "BuildPluginBody") + return pkg.DeclareName(declaration) + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + return append(files, &codegen.File{ + Path: "gen/http/plugin/plugin.go", + SectionTemplates: []*codegen.SectionTemplate{{ + Name: "plugin-init", + Data: &httpcodegen.InitData{ + Declaration: declaration, + Name: declaration.Name(), + }, + }}, + }), nil + }, + } + }) + + run, err := newGenerationRun("gen", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) +} + +// TestPluginOwnedHTTPDeclarationReplacementIsAccepted checks that a later +// plugin may replace a declaration with another name planned by the same run. +func TestPluginOwnedHTTPDeclarationReplacementIsAccepted(t *testing.T) { + root := codegen.RunDSL(t, httpdata.ServerSimpleRoutingDSL) + registry := newDefaultRegistry() + var ( + init *httpcodegen.InitData + declaration *codegen.NameDeclaration + replacement *codegen.NameDeclaration + laterRan bool + ) + registry.registerPlugin("a-add-init", "gen", pluginNormal, func() Plugin { + return Plugin{ + Plan: func(plan *Plan) error { + pkg, err := plan.Generation().ClaimPackage("generated.local/gen/http/plugin") + if err != nil { + return err + } + declaration = codegen.NewExactName(codegen.NameFunction, "BuildPluginBody") + replacement = codegen.NewExactName(codegen.NameFunction, "BuildOtherBody") + if err := pkg.DeclareName(declaration); err != nil { + return err + } + return pkg.DeclareName(replacement) + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + init = &httpcodegen.InitData{Declaration: declaration, Name: declaration.Name()} + return append(files, &codegen.File{ + Path: "gen/http/plugin/plugin.go", + SectionTemplates: []*codegen.SectionTemplate{{Name: "plugin-init", Data: init}}, + }), nil + }, + } + }) + registry.registerPlugin("b-replace-init", "gen", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + init.Declaration = replacement + init.Name = replacement.Name() + return files, nil + }} + }) + registry.registerPlugin("c-later", "gen", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + laterRan = true + return files, nil + }} + }) + + run, err := newGenerationRun("gen", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.True(t, laterRan) +} + +// TestPluginCallbackErrorIsPreserved checks that an ordinary callback failure +// is returned unchanged and stops later plugins. +func TestPluginCallbackErrorIsPreserved(t *testing.T) { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: "callback-error", + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + registry := newRegistry() + registry.addCommand("test") + laterRan := false + registry.registerPlugin("fail", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + return files, errors.New("callback failed") + }} + }) + registry.registerPlugin("z-later", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + laterRan = true + return files, nil + }} + }) + + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", []eval.Root{root}) + require.EqualError(t, err, "callback failed") + require.False(t, laterRan) +} + +// TestPluginDesignMutationErrorTakesPrecedence checks that Goa reports a +// forbidden design change even when the callback also returns its own error. +// The changed root would otherwise remain visible to later generation runs. +func TestPluginDesignMutationErrorTakesPrecedence(t *testing.T) { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: "before", + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + registry := newRegistry() + registry.addCommand("test") + registry.registerPlugin("mutate-and-fail", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + root.API.Name = "after" + return files, errors.New("callback failed") + }} + }) + + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", []eval.Root{root}) + require.ErrorContains(t, err, `plugin "mutate-and-fail" generate mutated prepared design`) + require.NotEqual(t, "callback failed", err.Error()) +} + +// TestPluginFactorySequentialIsolation verifies that every run invokes the +// factory again and no mutable callback state survives from an earlier run. +func TestPluginFactorySequentialIsolation(t *testing.T) { + registry := isolatedPluginRegistry(t) + + for i := range 2 { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: fmt.Sprintf("run-%d", i), + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + err := executeGeneration( + fmt.Sprintf("generated.local/gen%d", i), + []eval.Root{root}, + "test", + registry, + ) + require.NoError(t, err) + } +} + +// TestPluginFactoryConcurrentIsolation verifies that registry snapshots are +// race-safe and concurrent runs own independent callback state. +func TestPluginFactoryConcurrentIsolation(t *testing.T) { + registry := isolatedPluginRegistry(t) + var wait sync.WaitGroup + errs := make(chan error, 2) + for i := range 2 { + wait.Add(1) + go func(index int) { + defer wait.Done() + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: fmt.Sprintf("run-%d", index), + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + err := executeGeneration( + fmt.Sprintf("generated.local/gen%d", index), + []eval.Root{root}, + "test", + registry, + ) + errs <- err + }(i) + } + wait.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } +} + +// TestPreparedRootsBecomeExactGenerationSnapshot verifies that plugin +// preparation completes before Generation copies root membership and values. +func TestPreparedRootsBecomeExactGenerationSnapshot(t *testing.T) { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: "before", + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { + if !plan.Generation().HasRoot(root) { + return fmt.Errorf("prepared root is absent from generation") + } + if root.API.Name != "after" { + return fmt.Errorf("generation observed API name %q", root.API.Name) + } + return nil + }, + } + }) + registry.registerPlugin("prepare", "test", pluginNormal, func() Plugin { + return Plugin{Prepare: func(_ string, roots []eval.Root) error { + roots[0].(*expr.RootExpr).API.Name = "after" + return nil + }} + }) + + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + require.NoError(t, err) +} + +// TestPreparedRootsRejectNonAttributeMutations verifies that service, method, +// transport, and pointer-topology changes stop before the next callback. +func TestPreparedRootsRejectNonAttributeMutations(t *testing.T) { + tests := []struct { + name string + phase string + configure func(*registry, func()) + }{ + { + name: "service identity during plugin plan", + phase: `plugin "a-mutator" plan`, + configure: func(registry *registry, following func()) { + registry.addCommand("test") + registry.registerPlugin("a-mutator", "test", pluginNormal, func() Plugin { + return Plugin{Plan: func(plan *Plan) error { + plan.Generation().Roots()[0].(*expr.RootExpr).Services[0].Name = "changed" + return nil + }} + }) + registry.registerPlugin("z-following", "test", pluginNormal, func() Plugin { + return Plugin{Plan: func(_ *Plan) error { + following() + return nil + }} + }) + }, + }, + { + name: "method identity during core generate", + phase: `core "method-mutator" generate`, + configure: func(registry *registry, following func()) { + registry.addCommand( + "test", + func() coreGenerator { + return coreGenerator{name: "method-mutator", Generate: func(plan *Plan) ([]*codegen.File, error) { + plan.Generation().Roots()[0].(*expr.RootExpr).Services[0].Methods[0].Name = "changed" + return nil, nil + }} + }, + func() coreGenerator { + return coreGenerator{name: "following", Generate: func(_ *Plan) ([]*codegen.File, error) { + following() + return nil, nil + }} + }, + ) + }, + }, + { + name: "HTTP route during plugin generate", + phase: `plugin "a-mutator" generate`, + configure: func(registry *registry, following func()) { + registry.addCommand("test") + registry.registerPlugin("a-mutator", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + plan.Generation().Roots()[0].(*expr.RootExpr).API.HTTP.Services[0].HTTPEndpoints[0].Routes[0].Path = "/changed" + return files, nil + }} + }) + registry.registerPlugin("z-following", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + following() + return files, nil + }} + }) + }, + }, + { + name: "equal service replacement during core plan", + phase: `core "topology-mutator" plan`, + configure: func(registry *registry, following func()) { + registry.addCommand( + "test", + func() coreGenerator { + return coreGenerator{name: "topology-mutator", Plan: func(plan *Plan) error { + root := plan.Generation().Roots()[0].(*expr.RootExpr) + copy := *root.Services[0] + root.Services[0] = © + return nil + }} + }, + func() coreGenerator { + return coreGenerator{name: "following", Plan: func(_ *Plan) error { + following() + return nil + }} + }, + ) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, httpdata.AliasTypeDSL) + registry := newRegistry() + followingRan := false + test.configure(registry, func() { + followingRan = true + }) + + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + require.ErrorContains(t, err, test.phase+" mutated prepared design") + require.False(t, followingRan) + }) + } +} + +// TestPluginRegistrySealsOnFirstSnapshot verifies that a run cannot observe +// factories registered after the registry's immutable snapshot is established. +func TestPluginRegistrySealsOnFirstSnapshot(t *testing.T) { + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{} + }) + err := executeGeneration("generated.local/gen", nil, "test", registry) + require.NoError(t, err) + require.Panics(t, func() { + registry.registerPlugin("late", "test", pluginNormal, func() Plugin { + return Plugin{} + }) + }) +} + +// TestReleasedAndFactoryPluginsShareOneRun verifies that plugins registered +// through either API run in one order and receive the same prepared design and +// current file list. +func TestReleasedAndFactoryPluginsShareOneRun(t *testing.T) { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: "prepared", + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{Generate: func(_ *Plan) ([]*codegen.File, error) { + return []*codegen.File{{Path: "core"}}, nil + }} + }) + var events []string + registry.registeredPlugins = func() []registeredPluginDescriptor { + return []registeredPluginDescriptor{ + releasedPluginForTest("z-first", "test", pluginFirst, root, &events), + releasedPluginForTest("a-normal", "test", pluginNormal, root, &events), + releasedPluginForTest("a-last", "test", pluginLast, root, &events), + } + } + registerFactoryPluginForTest(registry, "a-first", pluginFirst, &events) + registerFactoryPluginForTest(registry, "z-normal", pluginNormal, &events) + registerFactoryPluginForTest(registry, "z-last", pluginLast, &events) + + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.Equal(t, []string{ + "prepare:a-first", "prepare:z-first", "prepare:a-normal", "prepare:z-normal", "prepare:a-last", "prepare:z-last", + "generate:a-first:core", "generate:z-first:factory:a-first", "generate:a-normal:released:z-first", + "generate:z-normal:released:a-normal", "generate:a-last:factory:z-normal", "generate:z-last:released:a-last", + }, events) + require.Equal(t, "factory:z-last", result.files[len(result.files)-1].Path) +} + +// TestReleasedDuplicatePluginsKeepRegistrationOrder verifies that callbacks +// with the same released command and name still run in registration order. +func TestReleasedDuplicatePluginsKeepRegistrationOrder(t *testing.T) { + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{Generate: func(_ *Plan) ([]*codegen.File, error) { + return []*codegen.File{{Path: "core"}}, nil + }} + }) + var events []string + registry.registeredPlugins = func() []registeredPluginDescriptor { + duplicate := func(event string) registeredPluginDescriptor { + return registeredPluginDescriptor{ + name: "same", + command: "test", + position: pluginNormal, + generate: func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + events = append(events, event+":"+files[len(files)-1].Path) + return append(files, &codegen.File{Path: event}), nil + }, + } + } + return []registeredPluginDescriptor{duplicate("first"), duplicate("second")} + } + + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", nil) + require.NoError(t, err) + require.Equal(t, []string{"first:core", "second:first"}, events) +} + +// TestReleasedPluginCallbackReceivesEachRun checks that the same registered +// function can run twice. Each call receives only that run's generated package, +// design roots, and files. +func TestReleasedPluginCallbackReceivesEachRun(t *testing.T) { + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{Generate: func(plan *Plan) ([]*codegen.File, error) { + root := plan.Generation().Roots()[0].(*expr.RootExpr) + return []*codegen.File{{Path: "core-" + root.API.Name}}, nil + }} + }) + + var ( + preparedPackages []string + preparedRoots [][]eval.Root + generatedPackages []string + generatedRoots [][]eval.Root + generatedFiles [][]*codegen.File + ) + //nolint:unparam // The released callback signature includes an error result. + prepare := func(genpkg string, roots []eval.Root) error { + preparedPackages = append(preparedPackages, genpkg) + preparedRoots = append(preparedRoots, append([]eval.Root(nil), roots...)) + return nil + } + //nolint:unparam // The released callback signature includes an error result. + generate := func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + generatedPackages = append(generatedPackages, genpkg) + generatedRoots = append(generatedRoots, append([]eval.Root(nil), roots...)) + generatedFiles = append(generatedFiles, append([]*codegen.File(nil), files...)) + return append(files, &codegen.File{Path: "released-" + roots[0].(*expr.RootExpr).API.Name}), nil + } + registry.registeredPlugins = func() []registeredPluginDescriptor { + return []registeredPluginDescriptor{{ + name: "released", + command: "test", + position: pluginNormal, + prepare: prepare, + generate: generate, + }} + } + + packages := []string{"generated.local/first", "generated.local/second"} + roots := []*expr.RootExpr{ + {API: &expr.APIExpr{Name: "first", RandomizerFactory: expr.NewDeterministicRandomizerFactory()}}, + {API: &expr.APIExpr{Name: "second", RandomizerFactory: expr.NewDeterministicRandomizerFactory()}}, + } + for index, root := range roots { + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + result, err := run.execute(packages[index], []eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "released-"+root.API.Name, result.files[1].Path) + } + + require.Equal(t, packages, preparedPackages) + require.Equal(t, packages, generatedPackages) + for index, root := range roots { + require.Len(t, preparedRoots[index], 1) + require.Same(t, root, preparedRoots[index][0]) + require.Len(t, generatedRoots[index], 1) + require.Same(t, root, generatedRoots[index][0]) + require.Len(t, generatedFiles[index], 1) + require.Equal(t, "core-"+root.API.Name, generatedFiles[index][0].Path) + } +} + +// TestReleasedPluginNilFileRemainsVisibleUntilMerge checks that one plugin may +// return a one-item list containing nil. The next plugin receives that list +// unchanged, and Goa omits nil before writing files. Released Goa accidentally +// panicked when nil was the only file; generation now handles every list size +// consistently. +func TestReleasedPluginNilFileRemainsVisibleUntilMerge(t *testing.T) { + codegen.RunDSL(t, func() { + }) + registry := newRegistry() + registry.addCommand("test") + observedNil := false + registry.registeredPlugins = func() []registeredPluginDescriptor { + return []registeredPluginDescriptor{ + { + name: "a-return-nil", + command: "test", + position: pluginNormal, + generate: func(_ string, _ []eval.Root, _ []*codegen.File) ([]*codegen.File, error) { + return []*codegen.File{nil}, nil + }, + }, + { + name: "b-observe-nil", + command: "test", + position: pluginNormal, + generate: func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + observedNil = len(files) == 1 && files[0] == nil + return files, nil + }, + }, + } + } + directory := t.TempDir() + writeGeneratedModule(t, filepath.Join(directory, codegen.Gendir), "generated.local/gen") + + outputs, err := generate(directory, "test", false, registry) + require.NoError(t, err) + require.True(t, observedNil) + require.Empty(t, outputs) +} + +// TestReleasedAndFactoryPluginDuplicatesStopBeforeCallbacks verifies that a +// command/name pair cannot be registered once through each API. +func TestReleasedAndFactoryPluginDuplicatesStopBeforeCallbacks(t *testing.T) { + registry := newRegistry() + registry.addCommand("test") + called := false + registry.registeredPlugins = func() []registeredPluginDescriptor { + return []registeredPluginDescriptor{{ + name: "duplicate", + command: "test", + position: pluginFirst, + generate: func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + called = true + return files, nil + }, + }} + } + registry.registerPlugin("duplicate", "test", pluginNormal, func() Plugin { + called = true + return Plugin{} + }) + + _, err := newGenerationRun("test", registry) + require.ErrorContains(t, err, `plugin "duplicate" is already registered for command "test"`) + require.False(t, called) +} + +// isolatedPluginRegistry builds a factory whose private phase counter must +// always start at zero and advance exactly once through the three phases. +func isolatedPluginRegistry(t *testing.T) *registry { + t.Helper() + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + phase := 0 + return coreGenerator{ + name: "state", + Plan: func(_ *Plan) error { + if phase != 0 { + return fmt.Errorf("core plan started at phase %d", phase) + } + phase++ + return nil + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + if phase != 1 { + return nil, fmt.Errorf("core generate started at phase %d", phase) + } + phase++ + return []*codegen.File{{Path: plan.Generation().GenPkg()}}, nil + }, + } + }) + registry.registerPlugin("state", "test", pluginNormal, func() Plugin { + var ( + phase int + preparedRoot eval.Root + planned *Plan + ) + return Plugin{ + Prepare: func(_ string, roots []eval.Root) error { + if phase != 0 { + return fmt.Errorf("prepare started at phase %d", phase) + } + if len(roots) != 1 { + return fmt.Errorf("prepare received %d roots", len(roots)) + } + preparedRoot = roots[0] + phase++ + return nil + }, + Plan: func(plan *Plan) error { + if phase != 1 { + return fmt.Errorf("plan started at phase %d", phase) + } + roots := plan.Generation().Roots() + if len(roots) != 1 || roots[0] != preparedRoot { + return fmt.Errorf("plan received another run's roots") + } + planned = plan + phase++ + return nil + }, + Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + if phase != 2 { + return nil, fmt.Errorf("generate started at phase %d", phase) + } + if plan != planned { + return nil, fmt.Errorf("generate received another run's plan") + } + if len(files) != 1 || files[0].Path != plan.Generation().GenPkg() { + return nil, fmt.Errorf("generate received another run's files") + } + phase++ + return files, nil + }, + } + }) + return registry +} + +// releasedPluginForTest creates an old-style callback that checks the exact +// package, root, and file list passed from the shared generation run. +func releasedPluginForTest(name, command string, position pluginPosition, root eval.Root, events *[]string) registeredPluginDescriptor { + return registeredPluginDescriptor{ + name: name, + command: command, + position: position, + prepare: func(genpkg string, roots []eval.Root) error { + if genpkg != "generated.local/gen" { + return fmt.Errorf("prepare received package %q", genpkg) + } + if len(roots) != 1 || roots[0] != root { + return fmt.Errorf("prepare received another run's roots") + } + *events = append(*events, "prepare:"+name) + return nil + }, + generate: func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + if genpkg != "generated.local/gen" { + return nil, fmt.Errorf("generate received package %q", genpkg) + } + if len(roots) != 1 || roots[0] != root { + return nil, fmt.Errorf("generate received another run's roots") + } + last := files[len(files)-1].Path + *events = append(*events, "generate:"+name+":"+last) + return append(files, &codegen.File{Path: "released:" + name}), nil + }, + } +} + +// registerFactoryPluginForTest adds a factory plugin that records its current +// input file and appends one file for the following plugin. +func registerFactoryPluginForTest(registry *registry, name string, position pluginPosition, events *[]string) { + registry.registerPlugin(name, "test", position, func() Plugin { + return Plugin{ + Prepare: func(_ string, _ []eval.Root) error { + *events = append(*events, "prepare:"+name) + return nil + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + last := files[len(files)-1].Path + *events = append(*events, "generate:"+name+":"+last) + return append(files, &codegen.File{Path: "factory:" + name}), nil + }, + } + }) +} diff --git a/codegen/generator/public_api_compatibility_test.go b/codegen/generator/public_api_compatibility_test.go new file mode 100644 index 0000000000..f719076502 --- /dev/null +++ b/codegen/generator/public_api_compatibility_test.go @@ -0,0 +1,62 @@ +// This file protects released generator types that do not expose or run the +// internal generation sequence. +package generator + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +// TestReleasedGeneratorFunctionType checks the released function signature. +func TestReleasedGeneratorFunctionType(t *testing.T) { + var generate Genfunc = func(string, []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{Path: "generated.go"}}, nil + } + files, err := generate("generated.local/gen", nil) + require.NoError(t, err) + require.Equal(t, "generated.go", files[0].Path) +} + +// TestReleasedGeneratorEntryPoints checks the generator functions that plugins +// can call directly or return from Generators. +func TestReleasedGeneratorEntryPoints(t *testing.T) { + var ( + service Genfunc = Service + transport Genfunc = Transport + openAPI Genfunc = OpenAPI + example Genfunc = Example + ) + require.NotNil(t, service) + require.NotNil(t, transport) + require.NotNil(t, openAPI) + require.NotNil(t, example) + + original := Generators + t.Cleanup(func() { + Generators = original + }) + Generators = func(command string) ([]Genfunc, error) { + if command != "custom" { + return nil, fmt.Errorf("unknown command %q", command) + } + return []Genfunc{func(string, []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{Path: "custom.go"}}, nil + }}, nil + } + generators, err := Generators("custom") + require.NoError(t, err) + require.Len(t, generators, 1) + require.NotNil(t, generators[0]) + + run, err := newGenerationRun("custom", newDefaultRegistry()) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", nil) + require.NoError(t, err) + require.Len(t, result.files, 1) + require.Equal(t, "custom.go", result.files[0].Path) +} diff --git a/codegen/generator/purity_test.go b/codegen/generator/purity_test.go index aa99e2ed31..b9e9c9723d 100644 --- a/codegen/generator/purity_test.go +++ b/codegen/generator/purity_test.go @@ -1,13 +1,13 @@ +// This file verifies through the production lifecycle boundary that only +// preparation and normalization may change evaluated design expressions. package generator import ( - "reflect" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" grpcdata "goa.design/goa/v3/grpc/codegen/testdata" @@ -15,50 +15,16 @@ import ( jsonrpcdata "goa.design/goa/v3/jsonrpc/codegen/testdata" ) -type ( - // attrState captures the mutable state of a design attribute expression: - // the identity of its type, the user type naming, the object shape and - // deep copies of the meta and validation expressions. Two snapshots of - // the same attribute are equal if and only if no reachable state was - // rewritten in between. - attrState struct { - Type uintptr - Primitive expr.Kind - TypeName string - UID string - Identifier string - Views []string - UTAttr uintptr - Fields []string - Description string - Meta expr.MetaExpr - Validation *expr.ValidationExpr - DefaultValue any - } - - // visitKey identifies a visited pointer during the design walk. The type - // disambiguates a struct from its first field which share the address. - visitKey struct { - ptr uintptr - typ reflect.Type - } -) - -// TestGeneratorsTreatDesignAsReadOnly is the design purity invariant: once -// eval finalization ran and codegen.NormalizeRoot applied the only sanctioned -// post-finalization rewrite, running every generator ("gen" and "example") -// must leave the design expression tree bit for bit unchanged. The fixtures -// cover alias chains, result views, websocket streaming, SSE with anonymous -// object payloads and results (the NormalizeRoot wrapping case), mixed -// HTTP+JSON-RPC transports and gRPC unions and streaming. +// TestGeneratorsTreatDesignAsReadOnly audits the persistent design state after +// generation construction applies the sanctioned normalization. Running every +// generator ("gen" and "example") must leave the prepared semantic design +// unchanged after each callback and completed render. The fixtures cover alias +// chains, result views, websocket streaming, SSE with anonymous object payloads +// and results, mixed HTTP+JSON-RPC transports, and gRPC unions and streaming. // -// Process global state is deliberately out of the snapshot: -// - expr.GeneratedResultTypes is appended to by expr.Dup when generators -// duplicate generated result types; it is a separate eval root, not part -// of the design tree (known purity hole, documented in expr/dup.go). -// - the example randomizer seen-value cache lives on the API expression -// example generator and is legitimately filled by example and OpenAPI -// generation; the walk skips it. +// The production lifecycle snapshot owns this audit. Dormant eval.DSLFunc +// closure captures and process-global state outside the prepared roots are not +// evaluated design input and remain outside this assertion. func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { cases := []struct { Name string @@ -66,6 +32,7 @@ func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { }{ {"alias-chains", httpdata.AliasTypeDSL}, {"result-views", httpdata.ResultBodyMultipleViewsDSL}, + {"result-collection-custom-view", resultCollectionCustomViewDSL}, {"websocket-bidirectional", httpdata.BidirectionalStreamingDSL}, {"sse-anonymous-object", httpdata.SSEObjectDSL}, {"jsonrpc-mixed-transport", jsonrpcdata.JSONRPCKitchenSinkDSL}, @@ -74,127 +41,67 @@ func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - root := expr.RunDSL(t, c.DSL) - codegen.NormalizeRoot(root) - before := snapshotDesign(root) + expr.RunDSL(t, c.DSL) + roots, err := eval.Context.Roots() + require.NoError(t, err) for _, cmd := range []string{"gen", "example"} { - genfuncs, err := Generators(cmd) + err := executeGeneration("generated.local/gen", roots, cmd, newDefaultRegistry()) require.NoError(t, err) - for _, gen := range genfuncs { - _, err := gen("gen", []eval.Root{root}) - require.NoError(t, err) - } - } - - after := snapshotDesign(root) - assert.Len(t, after, len(before), "attributes appeared in or disappeared from the design") - for att, b := range before { - a, ok := after[att] - if !assert.True(t, ok, "attribute %q (%p) disappeared from the design", b.Description, att) { - continue - } - assert.Equal(t, b, a, "attribute %q (%p) was mutated by a generator", b.Description, att) } }) } } -// snapshotDesign walks every expression reachable from the root via exported -// fields and captures the state of each attribute expression encountered. -func snapshotDesign(root *expr.RootExpr) map[*expr.AttributeExpr]attrState { - atts := make(map[*expr.AttributeExpr]attrState) - visited := make(map[visitKey]struct{}) - exampleGenType := reflect.TypeOf((*expr.ExampleGenerator)(nil)) - var walk func(v reflect.Value) - walk = func(v reflect.Value) { - switch v.Kind() { - case reflect.Pointer: - if v.IsNil() { - return - } - key := visitKey{ptr: v.Pointer(), typ: v.Type()} - if _, ok := visited[key]; ok { - return - } - visited[key] = struct{}{} - if v.Type() == exampleGenType { - // The example generator carries the randomizer seen-value - // cache which generation legitimately fills; it is not part - // of the design. - return - } - if v.CanInterface() { - if att, ok := v.Interface().(*expr.AttributeExpr); ok { - atts[att] = snapshotAttribute(att) - } - } - walk(v.Elem()) - case reflect.Interface: - if v.IsNil() { - return - } - walk(v.Elem()) - case reflect.Struct: - for i := range v.NumField() { - if v.Type().Field(i).PkgPath != "" { - continue // unexported - } - walk(v.Field(i)) - } - case reflect.Slice, reflect.Array: - for i := range v.Len() { - walk(v.Index(i)) - } - case reflect.Map: - iter := v.MapRange() - for iter.Next() { - walk(iter.Key()) - walk(iter.Value()) - } - } - } - walk(reflect.ValueOf(root)) - return atts +// resultCollectionCustomViewDSL defines a generated collection whose method +// result selects a non-default view. Copying this result during planning must +// not add another collection to the evaluated design. +func resultCollectionCustomViewDSL() { + item := dsl.ResultType("application/vnd.item", func() { + dsl.Attribute("name", expr.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + dsl.View("tiny", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("items", func() { + dsl.Method("list", func() { + dsl.Result(dsl.CollectionOf(item), func() { + dsl.View("tiny") + }) + dsl.HTTP(func() { + dsl.GET("/items") + }) + }) + }) } -// snapshotAttribute captures the mutable state of att. Meta and validation -// are deep copied so in-place writes are detected; the type is captured by -// identity together with the user type name, attribute and shape so renames, -// attribute swaps and field changes are detected too. -func snapshotAttribute(att *expr.AttributeExpr) attrState { - s := attrState{ - Description: att.Description, - DefaultValue: att.DefaultValue, - } - if att.Meta != nil { - s.Meta = att.Meta.Dup() - } - if att.Validation != nil { - s.Validation = att.Validation.Dup() - } - switch dt := att.Type.(type) { - case nil: - case expr.Primitive: - s.Primitive = dt.Kind() - case expr.UserType: - s.Type = reflect.ValueOf(att.Type).Pointer() - s.TypeName = dt.Name() - s.UID = dt.ID() - s.UTAttr = reflect.ValueOf(dt.Attribute()).Pointer() - if rt, ok := dt.(*expr.ResultTypeExpr); ok { - s.Identifier = rt.Identifier - for _, v := range rt.Views { - s.Views = append(s.Views, v.Name) - } - } - default: - s.Type = reflect.ValueOf(att.Type).Pointer() - } - if obj := expr.AsObject(att.Type); obj != nil { - for _, nat := range *obj { - s.Fields = append(s.Fields, nat.Name) - } - } - return s +// TestPreparedRootsRejectAttributeMutation proves that generation stops when +// a core planner changes an attribute after the mutable lifecycle phase. +func TestPreparedRootsRejectAttributeMutation(t *testing.T) { + root := expr.RunDSL(t, httpdata.AliasTypeDSL) + registry := newRegistry() + target := root.Types[0].Attribute() + followingRan := false + registry.addCommand( + "test", + func() coreGenerator { + return coreGenerator{name: "attribute-mutator", Plan: func(_ *Plan) error { + target.Description = "changed after preparation" + return nil + }} + }, + func() coreGenerator { + return coreGenerator{name: "following", Plan: func(_ *Plan) error { + followingRan = true + return nil + }} + }, + ) + + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + require.ErrorContains(t, err, `core "attribute-mutator" plan mutated prepared design`) + require.False(t, followingRan) } diff --git a/codegen/generator/registry_test.go b/codegen/generator/registry_test.go new file mode 100644 index 0000000000..e4d02a394b --- /dev/null +++ b/codegen/generator/registry_test.go @@ -0,0 +1,56 @@ +// This file supplies isolated command registries to generator integration +// tests. Test factories adapt the transitional Generation-based core renderers +// without restoring mutable production hooks. +package generator + +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +type ( + // testGenfunc describes one retained planner and renderer used by an + // isolated generator command. + testGenfunc struct { + // Plan declares package symbols and retains the analysis used by Generate. + Plan func(*Plan) error + // Generate renders fixture files from the linked plan. + Generate func(*Plan) ([]*codegen.File, error) + } +) + +// testRegistry returns an isolated registry for one command. +func testRegistry(command string, factories ...generatorFactory) *registry { + registry := newRegistry() + registry.addCommand(command, factories...) + return registry +} + +// testRegistryFromGenfuncs creates one isolated command from fixture callbacks. +func testRegistryFromGenfuncs(callbacks []testGenfunc) *registry { + factories := make([]generatorFactory, len(callbacks)) + for i, callback := range callbacks { + factories[i] = testGenerator(callback.Plan, callback.Generate) + } + return testRegistry("gen", factories...) +} + +// testRenderOnly adapts a root-based rendering fixture into a test callback. +func testRenderOnly(generate func(string, []eval.Root) ([]*codegen.File, error)) testGenfunc { + return testGenfunc{Generate: func(plan *Plan) ([]*codegen.File, error) { + generation := plan.Generation() + return generate(generation.GenPkg(), generation.Roots()) + }} +} + +// testGenerator returns a fresh core generator that receives one retained plan +// from declaration collection through rendering. +func testGenerator(plan func(*Plan) error, generate func(*Plan) ([]*codegen.File, error)) generatorFactory { + return func() coreGenerator { + return coreGenerator{ + name: "test", + Plan: plan, + Generate: generate, + } + } +} diff --git a/codegen/generator/run_examples.go b/codegen/generator/run_examples.go new file mode 100644 index 0000000000..1665ebb5b9 --- /dev/null +++ b/codegen/generator/run_examples.go @@ -0,0 +1,21 @@ +// This file creates a separate example generator for each evaluated Goa root +// in one command. Repeated or concurrent commands therefore do not share the +// random value sequence or the record of types currently being visited. +package generator + +import ( + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// newExampleGenerators creates one fresh mutable generator for every Goa +// design root participating in a run. +func newExampleGenerators(roots []eval.Root) map[*expr.RootExpr]*expr.ExampleGenerator { + generators := make(map[*expr.RootExpr]*expr.ExampleGenerator) + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + generators[design] = expr.NewExampleGenerator(design.API.RandomizerFactory) + } + } + return generators +} diff --git a/codegen/generator/service.go b/codegen/generator/service.go index b4c3782b8e..3e7efef654 100644 --- a/codegen/generator/service.go +++ b/codegen/generator/service.go @@ -1,3 +1,5 @@ +// This file assembles generated service files after every participating Goa +// design root has submitted its declarations and all package names are final. package generator import ( @@ -7,78 +9,49 @@ import ( "goa.design/goa/v3/expr" ) -// Service iterates through the roots and returns the files needed to render -// the service code. It returns an error if the roots slice does not include -// a goa design. +// Service returns the files that define service types, endpoints, clients, and +// result views for roots. func Service(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - var files []*codegen.File - var userTypePkgs = make(map[string][]string) - for _, root := range roots { - r, ok := root.(*expr.RootExpr) - if !ok { - continue - } - // Create service data - services := service.NewServicesData(r) - - for _, s := range r.Services { - d := services.Get(s.Name) - service.SetUserTypeImports(genpkg, d) - - // Make sure service is first so name scope is - // properly initialized. - svcFiles := service.Files(genpkg, s, services, userTypePkgs) - addServiceImports(svcFiles, d) - files = append(files, svcFiles...) - - endpointFiles := []*codegen.File{ - service.EndpointFile(genpkg, s, services), - service.ClientFile(genpkg, s, services), - } - addServiceImports(endpointFiles, d) - files = append(files, endpointFiles...) - - if f := service.ViewsFile(genpkg, s, services); f != nil { - addServiceImports([]*codegen.File{f}, d) - files = append(files, f) - } - convFiles, err := service.ConvertFiles(r, s, services) - if err != nil { - return nil, err - } - files = append(files, convFiles...) - } - } - return files, nil + return runStandaloneGenerator(genpkg, roots, serviceGeneratorFactory) } -func addServiceImports(files []*codegen.File, d *service.Data) { - for _, f := range files { - if len(f.SectionTemplates) == 0 { - continue - } - service.AddServiceDataMetaTypeImports(f.SectionTemplates[0], d) - service.AddUserTypeImports(f.SectionTemplates[0], d) - } +// serviceFiles returns the service files described by plan's completed package +// declarations and the example generator created for this run. +func serviceFiles(plan *Plan) ([]*codegen.File, error) { + return service.Files(plan.serviceOrder...) } -func addServicesImports(files []*codegen.File, services *service.ServicesData, svcs []*expr.ServiceExpr) { - for _, s := range svcs { - addServiceImports(files, services.Get(s.Name)) +// planServiceData declares service-owned generated package types for every Goa +// design root in generation. +func planServiceData(plan *Plan) error { + if plan.services != nil { + return nil } -} - -func addMetaTypeImports(files []*codegen.File, d *service.Data) { - for _, f := range files { - if len(f.SectionTemplates) == 0 { - continue - } - service.AddServiceDataMetaTypeImports(f.SectionTemplates[0], d) + plan.services = make(map[*expr.RootExpr]*service.Plan) + roots := serviceRoots(plan.Generation().Roots()) + inputs := make([]service.PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = service.PlanInput{Root: root, Examples: plan.exampleGenerator(root)} } + servicePlans, err := service.NewPlans(plan.Generation(), inputs...) + if err != nil { + return err + } + for index, root := range roots { + plan.services[root] = servicePlans[index] + } + plan.serviceOrder = servicePlans + return nil } -func addServicesMetaTypeImports(files []*codegen.File, services *service.ServicesData, svcs []*expr.ServiceExpr) { - for _, s := range svcs { - addMetaTypeImports(files, services.Get(s.Name)) +// serviceRoots returns every Goa design root that emits files into the same +// generated package tree. +func serviceRoots(roots []eval.Root) []*expr.RootExpr { + var designRoots []*expr.RootExpr + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + designRoots = append(designRoots, design) + } } + return designRoots } diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go new file mode 100644 index 0000000000..82deb40dbe --- /dev/null +++ b/codegen/generator/service_union_package_scope_test.go @@ -0,0 +1,1178 @@ +// This file verifies that service generation allocates relocated union symbols +// once across every design root that contributes to a generated Go package. +package generator + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + servicecodegen "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestRelocatedUnionPackageNamesCompile verifies that two services and their +// HTTP and gRPC transports compile against distinct unions in one shared package. +func TestRelocatedUnionPackageNamesCompile(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + root := func() { + dsl.API("relocated union package names", func() {}) + + firstInput := dsl.Type("FirstInput", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Field(1, "record", func() { + dsl.OneOf("Nested", func() { + dsl.Field(1, "text", dsl.String) + }) + dsl.Required("Nested") + }) + }) + dsl.Required("Value") + }) + secondInput := dsl.Type("SecondInput", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Field(1, "record", func() { + dsl.OneOf("Nested", func() { + dsl.Field(1, "text", dsl.Int) + }) + dsl.Required("Nested") + }) + }) + dsl.Required("Value") + }) + dsl.Service("First", func() { + dsl.Method("Read", func() { + dsl.Payload(firstInput) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/first") + dsl.Response(200) + }) + dsl.GRPC(func() {}) + }) + dsl.Method("ReadJSON", func() { + dsl.Payload(firstInput) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + dsl.Service("Second", func() { + dsl.Method("Read", func() { + dsl.Payload(secondInput) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/second") + dsl.Response(200) + }) + dsl.GRPC(func() {}) + }) + dsl.Method("ReadJSON", func() { + dsl.Payload(secondInput) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + } + codegen.RunDSL(t, root) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + for _, path := range []string{ + filepath.Join("types", "first_input.go"), + filepath.Join("types", "second_input.go"), + filepath.Join("http", "first", "server", "server.go"), + filepath.Join("http", "second", "server", "server.go"), + filepath.Join("grpc", "first", "server", "server.go"), + filepath.Join("grpc", "second", "server", "server.go"), + filepath.Join("jsonrpc", "first", "server", "server.go"), + filepath.Join("jsonrpc", "second", "server", "server.go"), + } { + require.FileExists(t, filepath.Join(genDir, path)) + } + runGeneratedTests(t, genDir) +} + +// TestInheritedTransportErrorMappingsCompileWithMethodErrors verifies reusable +// HTTP and gRPC response policy binds to the equivalent error value declared by +// the endpoint method instead of retaining the API declaration object. +func TestInheritedTransportErrorMappingsCompileWithMethodErrors(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + dsl.API("error policy", func() { + dsl.Error("bad_request", dsl.String) + dsl.HTTP(func() { dsl.Response(dsl.StatusBadRequest, "bad_request") }) + dsl.GRPC(func() { dsl.Response("bad_request", dsl.CodeInvalidArgument) }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Error("bad_request", dsl.String) + dsl.HTTP(func() { dsl.GET("/values") }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestNestedTransportMetadataOwnsRecursiveImports verifies conversion helpers +// import a custom field type nested inside a relocated service declaration. +func TestNestedTransportMetadataOwnsRecursiveImports(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + outer := dsl.Type("Outer", func() { + dsl.Meta("struct:pkg:path", "domain/outer") + dsl.Field(1, "value", dsl.String, func() { + dsl.Meta("struct:field:type", "custom.Value", "generated.local/gen/custom/value", "custom") + }) + }) + dsl.Service("Values", func() { + dsl.Method("HTTP", func() { + dsl.Payload(outer) + dsl.HTTP(func() { dsl.POST("/values") }) + }) + dsl.Method("GRPC", func() { + dsl.Payload(outer) + dsl.GRPC(func() {}) + }) + dsl.Method("JSONRPC", func() { + dsl.Payload(outer) + dsl.JSONRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + writeStubPackage(t, filepath.Join(genDir, "custom", "value"), "custom") + runGeneratedTests(t, genDir) +} + +// TestTransportServiceImportsUseFrozenAliases verifies a service package whose +// natural name collides with a fixed runtime import is declared and referenced +// with the same generation-owned qualifier in every transport. +func TestTransportServiceImportsUseFrozenAliases(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + dsl.Service("Goa", func() { + for _, transport := range []string{"HTTP", "GRPC", "JSONRPC"} { + dsl.Method(transport, func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + switch transport { + case "HTTP": + dsl.HTTP(func() { dsl.POST("/values") }) + case "GRPC": + dsl.GRPC(func() {}) + case "JSONRPC": + dsl.JSONRPC(func() {}) + } + }) + } + }) + dsl.Service("Goahttp", func() { + dsl.Method("HTTP", func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + dsl.HTTP(func() { dsl.POST("/http-values") }) + }) + }) + dsl.Service("Goapb", func() { + dsl.Method("GRPC", func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestInheritedTransportErrorsOwnImports verifies API-level response policy +// imports the relocated effective error referenced by generated HTTP and gRPC +// encoders even though the method does not redeclare it. +func TestInheritedTransportErrorsOwnImports(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + fault := dsl.Type("Fault", func() { + dsl.Meta("struct:pkg:path", "domain/errors") + dsl.Attribute("message", dsl.String) + }) + dsl.API("error imports", func() { + dsl.Error("fault", fault) + dsl.HTTP(func() { dsl.Response(dsl.StatusBadRequest, "fault") }) + dsl.GRPC(func() { dsl.Response("fault", dsl.CodeInvalidArgument) }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/values") }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestServiceUnionGeneratedBranchShapesCompile verifies that generated branch +// aliases with one natural name but different primitive shapes remain distinct. +func TestServiceUnionGeneratedBranchShapesCompile(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) + + codegen.RunDSL(t, func() { + first := dsl.Type("FirstValue", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + second := dsl.Type("SecondValue", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.Int) + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(first) + dsl.Result(second) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + unionSource, err := os.ReadFile(filepath.Join(genDir, "types", "unions.go")) + require.NoError(t, err) + require.Contains(t, string(unionSource), "type Value struct") + require.Contains(t, string(unionSource), "type Value2 struct") + runGeneratedTests(t, genDir) +} + +// TestServiceUnionFamilyNamesAvoidExactDeclarations verifies that union +// constants and constructors cannot collide with exact DSL type names. +func TestServiceUnionFamilyNamesAvoidExactDeclarations(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) + + codegen.RunDSL(t, func() { + kind := dsl.Type("ValueKindText", dsl.String) + constructor := dsl.Type("NewValueText", dsl.String) + payload := dsl.Type("Payload", func() { + dsl.Attribute("kind", kind) + dsl.Attribute("constructor", constructor) + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestServiceFilesOwnTheirImports verifies that imports used by one service do +// not leak into another service file generated from the same design root. +func TestServiceFilesOwnTheirImports(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + dsl.API("file-owned imports", func() {}) + firstInput := dsl.Type("FirstInput", func() { + dsl.Meta("struct:pkg:path", "first/shared") + dsl.Field(1, "value", dsl.String) + }) + secondInput := dsl.Type("SecondInput", func() { + dsl.Meta("struct:pkg:path", "second/shared") + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("First", func() { + dsl.Method("Read", func() { + dsl.Payload(firstInput) + dsl.HTTP(func() { + dsl.POST("/first") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("Second", func() { + dsl.Method("Read", func() { + dsl.Payload(secondInput) + dsl.HTTP(func() { + dsl.POST("/second") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestRawBodyStructsRemainInEndpointsPackage verifies relocated payload and +// result declarations never relocate the request/response wrappers consumed by +// the raw HTTP body path. +func TestRawBodyStructsRemainInEndpointsPackage(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + upload := dsl.Type("Upload", func() { + dsl.Meta("struct:pkg:path", "domain/types") + dsl.Attribute("length", dsl.Int) + dsl.Required("length") + }) + download := dsl.Type("Download", func() { + dsl.Meta("struct:pkg:path", "domain/types") + dsl.Attribute("length", dsl.Int) + dsl.Required("length") + }) + dsl.Service("RawBodies", func() { + dsl.Method("Upload", func() { + dsl.Payload(upload) + dsl.HTTP(func() { + dsl.POST("/upload") + dsl.Header("length:Content-Length") + dsl.SkipRequestBodyEncodeDecode() + dsl.Response(dsl.StatusNoContent) + }) + }) + dsl.Method("Download", func() { + dsl.Result(download) + dsl.HTTP(func() { + dsl.GET("/download") + dsl.SkipResponseBodyEncodeDecode() + dsl.Response(dsl.StatusOK, func() { + dsl.Header("length:Content-Length") + }) + }) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + + clientSource, err := os.ReadFile(filepath.Join(genDir, "http", "raw_bodies", "client", "client.go")) + require.NoError(t, err) + require.Contains(t, string(clientSource), "&rawbodies.DownloadResponseData") + require.NotContains(t, string(clientSource), "types.DownloadResponseData") + codecSource, err := os.ReadFile(filepath.Join(genDir, "http", "raw_bodies", "client", "encode_decode.go")) + require.NoError(t, err) + require.Contains(t, string(codecSource), "*rawbodies.UploadRequestData") + require.NotContains(t, string(codecSource), "types.UploadRequestData") + runGeneratedTests(t, genDir) +} + +// TestServiceReferencesUseImportPathAliases verifies that one service can +// reference generated packages with the same Go package name without emitting +// duplicate import aliases or ambiguous qualified references. +func TestServiceReferencesUseImportPathAliases(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) + + codegen.RunDSL(t, func() { + dsl.API("path-owned aliases", func() {}) + first := dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", "first/shared") + dsl.Attribute("value", dsl.String) + }) + second := dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", "second/shared") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("First", func() { + dsl.Payload(first) + }) + dsl.Method("Second", func() { + dsl.Payload(second) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) + require.NoError(t, err) + code := string(content) + require.Contains(t, code, `shared "generated.local/gen/first/shared"`) + require.Contains(t, code, `shared2 "generated.local/gen/second/shared"`) + require.Contains(t, code, `*shared.First`) + require.Contains(t, code, `*shared2.Second`) + runGeneratedTests(t, genDir) +} + +// TestTransportReferencesUseImportPathAliases verifies HTTP, gRPC, and +// JSON-RPC files qualify two same-basename service packages with the aliases +// frozen by the shared generation. +func TestTransportReferencesUseImportPathAliases(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + first := dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", "first/shared") + dsl.Field(1, "value", dsl.String) + }) + second := dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", "second/shared") + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("Values", func() { + for _, method := range []struct { + name string + path string + payload expr.UserType + }{ + {"HTTPFirst", "/http/first", first}, + {"HTTPSecond", "/http/second", second}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.payload) + dsl.HTTP(func() { dsl.POST(method.path) }) + }) + } + for _, method := range []struct { + name string + payload expr.UserType + }{ + {"GRPCFirst", first}, + {"GRPCSecond", second}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.payload) + dsl.GRPC(func() {}) + }) + } + for _, method := range []struct { + name string + payload expr.UserType + }{ + {"JSONFirst", first}, + {"JSONSecond", second}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.payload) + dsl.JSONRPC(func() {}) + }) + } + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + for _, transport := range []string{"http", "grpc", "jsonrpc"} { + source := generatedTreeSource(t, filepath.Join(genDir, transport, "values")) + require.Contains(t, source, `shared "generated.local/gen/first/shared"`) + require.Contains(t, source, `shared2 "generated.local/gen/second/shared"`) + require.Contains(t, source, "shared.First") + require.Contains(t, source, "shared2.Second") + } + runGeneratedTests(t, genDir) +} + +// generatedTreeSource returns the concatenated Go source below root in path +// order so tests can assert file-owned imports without depending on which +// transport file contains a conversion helper. +func generatedTreeSource(t *testing.T, root string) string { + t.Helper() + var source strings.Builder + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".go" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + source.Write(data) + return nil + }) + require.NoError(t, err) + return source.String() +} + +// TestNamedUnionBranchImportsReferenceOnly verifies that unions.go does not +// expand a named branch definition and import packages used only where that +// named type itself is declared. +func TestNamedUnionBranchImportsReferenceOnly(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) + + codegen.RunDSL(t, func() { + dsl.API("named branch imports", func() {}) + value := dsl.Type("Value", func() { + dsl.OneOf("choice", func() { + dsl.Attribute("external", dsl.String, func() { + dsl.Meta("struct:field:type", "json.Value", "generated.local/gen/custom/json", "json") + }) + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(value) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + writeStubPackage(t, filepath.Join(genDir, "custom", "json"), "json") + content, err := os.ReadFile(filepath.Join(genDir, "values", "unions.go")) + require.NoError(t, err) + code := string(content) + require.Contains(t, code, `"encoding/json"`) + require.NotContains(t, code, `"generated.local/gen/custom/json"`) + runGeneratedTests(t, genDir) +} + +// TestNormalizedMethodTypesUseServicePackageNames verifies that raw method +// object wrappers collide only with declarations emitted in the same service +// package, never with a nested declaration relocated elsewhere. +func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) + + t.Run("relocated name does not collide", func(t *testing.T) { + codegen.RunDSL(t, func() { + dsl.API("relocated wrapper names", func() {}) + relocated := dsl.Type("UsePayload", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Other", func() { + dsl.Payload(func() { + dsl.Field(1, "nested", relocated) + }) + dsl.HTTP(func() { + dsl.POST("/other") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + dsl.Method("Use", func() { + dsl.Payload(func() { + dsl.Field(1, "value", dsl.String) + }) + dsl.HTTP(func() { + dsl.POST("/use") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) + require.NoError(t, err) + require.Contains(t, string(content), "type UsePayload struct") + require.NotContains(t, string(content), "type UsePayload2 struct") + runGeneratedTests(t, genDir) + }) + + t.Run("local name collides", func(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + codegen.RunDSL(t, func() { + dsl.API("local wrapper names", func() {}) + local := dsl.Type("UsePayload", func() { + dsl.Field(1, "existing", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Existing", func() { + dsl.Payload(local) + dsl.HTTP(func() { + dsl.POST("/existing") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + dsl.Method("Use", func() { + dsl.Payload(func() { + dsl.Field(1, "value", dsl.String) + }) + dsl.HTTP(func() { + dsl.POST("/use") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) + require.NoError(t, err) + code := string(content) + require.Contains(t, code, "type UsePayload struct") + require.Contains(t, code, "type UsePayload2 struct") + runGeneratedTests(t, genDir) + }) +} + +// TestNestedRelocatedDeclarationsOwnTheirImports verifies that metadata imports +// used by two relocated declarations stay in their respective declaration +// files and do not leak into the service file that references their package. +func TestNestedRelocatedDeclarationsOwnTheirImports(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) + + codegen.RunDSL(t, func() { + dsl.API("nested file-owned imports", func() {}) + outer := dsl.Type("Outer", func() { + dsl.Meta("struct:pkg:path", "models") + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "shared.Value", "generated.local/gen/custom/first/shared", "shared") + }) + }) + inner := dsl.Type("Inner", func() { + dsl.Meta("struct:pkg:path", "models") + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "shared.Value", "generated.local/gen/custom/second/shared", "shared") + }) + }) + dsl.Service("Nested", func() { + dsl.Method("Outer", func() { + dsl.Payload(outer) + }) + dsl.Method("Inner", func() { + dsl.Payload(inner) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + writeStubPackage(t, filepath.Join(genDir, "custom", "first", "shared"), "shared") + writeStubPackage(t, filepath.Join(genDir, "custom", "second", "shared"), "shared") + runGeneratedTests(t, genDir) +} + +// TestTransportSectionsOwnTheirImports verifies that a streaming transport +// file imports only the declarations used by the streaming endpoints it +// renders, even when another endpoint uses the same package name elsewhere. +func TestTransportSectionsOwnTheirImports(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("transport section imports", func() {}) + streamMessage := dsl.Type("StreamMessage", func() { + dsl.Meta("struct:pkg:path", "stream/shared") + dsl.Attribute("value", dsl.String) + }) + request := dsl.Type("Request", func() { + dsl.Meta("struct:pkg:path", "request/shared") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Messages", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(streamMessage) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.Response(200) + }) + }) + dsl.Method("Create", func() { + dsl.Payload(request) + dsl.HTTP(func() { + dsl.POST("/messages") + dsl.Response(204) + }) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planTransportData) + files, err := testTransportFiles(plan) + require.NoError(t, err) + + var header strings.Builder + for _, file := range files { + if filepath.ToSlash(file.Path) != "gen/http/messages/server/websocket.go" { + continue + } + require.NoError(t, file.SectionTemplates[0].Write(&header)) + break + } + require.NotEmpty(t, header.String()) + require.Contains(t, header.String(), `"generated.local/gen/stream/shared"`) + require.NotContains(t, header.String(), `"generated.local/gen/request/shared"`) +} + +// TestRelocatedStreamingUnionReferencesCompile verifies ordinary HTTP +// WebSocket and SSE files and JSON-RPC SSE files resolve relocated streaming +// declarations through the frozen service packages. +func TestRelocatedStreamingUnionReferencesCompile(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + streamInput := relocatedStreamingType("StreamInput", "InputChoice", dsl.String) + streamOutput := relocatedStreamingType("StreamOutput", "OutputChoice", dsl.Int) + sseEvent := dsl.Type("SSEEvent", func() { + dsl.Attribute("data", streamOutput) + dsl.Attribute("id", dsl.String) + dsl.Required("data", "id") + }) + dsl.Service("HTTPStreams", func() { + dsl.Method("Socket", func() { + dsl.StreamingPayload(streamInput) + dsl.StreamingResult(streamOutput) + dsl.HTTP(func() { dsl.GET("/socket") }) + }) + dsl.Method("Events", func() { + dsl.StreamingResult(sseEvent) + dsl.HTTP(func() { + dsl.GET("/events") + dsl.ServerSentEvents("data", func() { dsl.SSEEventID("id") }) + }) + }) + }) + dsl.Service("JSONEvents", func() { + dsl.Method("Events", func() { + dsl.StreamingResult(sseEvent) + dsl.JSONRPC(func() { + dsl.ServerSentEvents("data", func() { dsl.SSEEventID("id") }) + }) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + for _, path := range []string{ + filepath.Join("http", "http_streams", "server", "websocket.go"), + filepath.Join("http", "http_streams", "server", "sse.go"), + filepath.Join("jsonrpc", "json_events", "server", "sse.go"), + } { + require.FileExists(t, filepath.Join(genDir, path)) + } + runGeneratedTests(t, genDir) +} + +// relocatedStreamingType builds an object with a nested union that is emitted +// in the shared streaming package used by the integration test. +func relocatedStreamingType(name, unionName string, value expr.DataType) expr.UserType { + return dsl.Type(name, func() { + dsl.Meta("struct:pkg:path", "stream/types") + dsl.OneOf(unionName, func() { + dsl.Attribute("value", value) + }) + dsl.Required(unionName) + }) +} + +// writeStubPackage creates the external package referenced by struct:field:type +// metadata inside the generated module used by the integration test. +func writeStubPackage(t *testing.T, dir, packageName string) { + t.Helper() + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "value.go"), + []byte("package "+packageName+"\n\ntype Value string\n"), + 0o600, + )) +} + +func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { + roots := []eval.Root{ + codegen.RunDSL(t, unusedRelocatedValueRoot()), + codegen.RunDSL(t, relocatedUnionRoot("ZExistingValue", "FirstService")), + codegen.RunDSL(t, relocatedUnionRoot("MExistingValue", "SecondService")), + codegen.RunDSL(t, relocatedUnionRoot("AAddedValue", "ThirdService")), + codegen.RunDSL(t, relocatedDifferentUnionRoot()), + codegen.RunDSL(t, relocatedTopLevelValueRoot()), + } + plan := mustTestPlan(t, "goa.design/goa/example", roots, planServiceData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + + var generated strings.Builder + for _, file := range files { + if !strings.Contains(file.Path, filepath.Join("gen", "types")) { + continue + } + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&generated)) + } + } + code := generated.String() + require.Equal(t, 1, strings.Count(code, "type Value struct {"), code) + require.Contains(t, code, "type Value struct {\n\tText string", code) + require.Equal(t, 1, strings.Count(code, "type Value2 struct {"), code) + require.Equal(t, 1, strings.Count(code, "type Value3 struct {"), code) + require.Equal(t, 1, strings.Count(code, "type Value2Kind string"), code) + require.Equal(t, 1, strings.Count(code, "type Value3Kind string"), code) + require.Equal(t, + []string{"Value2", "Value2", "Value2", "Value3"}, + []string{ + unionFieldType(code, "ZExistingValue"), + unionFieldType(code, "MExistingValue"), + unionFieldType(code, "AAddedValue"), + unionFieldType(code, "DifferentValue"), + }, + ) +} + +// TestServiceRelocatedUnionOwnerCompilesAcrossGeneration verifies a complete +// root analysis emits one shared relocated union for every referencing service. +func TestServiceRelocatedUnionOwnerCompilesAcrossGeneration(t *testing.T) { + root := codegen.RunDSL(t, sharedRelocatedUnionRoot()) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData) + files, err := servicecodegen.Files(plan.Service(root)) + require.NoError(t, err) + dir := t.TempDir() + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + writeGeneratedModule(t, dir, "generated.local") + runGeneratedTests(t, dir) +} + +// TestServiceAndExamplesCompileWithImportQualifierCollisions verifies that +// fixed packages, generated service packages, generated views packages, and +// metadata packages share one path-owned qualifier mapping. +func TestServiceAndExamplesCompileWithImportQualifierCollisions(t *testing.T) { + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.value", func() { + dsl.TypeName("Value") + dsl.Attribute("custom", dsl.String, func() { + dsl.Meta("struct:field:type", "valuesviews.Value", "generated.local/custom/views", "valuesviews") + }) + dsl.View("default", func() { + dsl.Attribute("custom") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String, func() { + dsl.Meta("struct:field:type", "values.Value", "generated.local/custom/values", "values") + }) + dsl.Result(result) + }) + }) + dsl.Service("Fmt", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String, func() { + dsl.Meta("struct:field:type", "strings.Value", "generated.local/custom/strings", "strings") + }) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData) + servicePlan := plan.Service(root) + + dir := t.TempDir() + files, err := testServiceFiles(plan) + require.NoError(t, err) + files = append(files, servicecodegen.ExampleServiceFiles(servicePlan)...) + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + writeGeneratedModule(t, dir, "generated.local") + writeStubPackage(t, filepath.Join(dir, "custom", "strings"), "strings") + writeStubPackage(t, filepath.Join(dir, "custom", "values"), "values") + writeStubPackage(t, filepath.Join(dir, "custom", "views"), "valuesviews") + runGeneratedTests(t, dir) +} + +// TestFixedRuntimeAliasesCompileWithGoaAndLogServices verifies that generated +// service imports are suffixed when static interceptor templates require the +// goa and log qualifiers for their runtime packages. +func TestFixedRuntimeAliasesCompileWithGoaAndLogServices(t *testing.T) { + root := codegen.RunDSL(t, func() { + interceptor := dsl.Interceptor("Trace") + for _, name := range []string{"Goa", "Log"} { + dsl.Service(name, func() { + dsl.ServerInterceptor(interceptor) + dsl.Method("Read", func() {}) + }) + } + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData) + servicePlan := plan.Service(root) + + dir := t.TempDir() + files, err := testServiceFiles(plan) + require.NoError(t, err) + files = append(files, servicecodegen.ExampleInterceptorsFiles(servicePlan)...) + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + goaSource, err := os.ReadFile(filepath.Join(dir, "interceptors", "goa_server.go")) + require.NoError(t, err) + require.Contains(t, string(goaSource), `goa "goa.design/goa/v3/pkg"`) + require.Contains(t, string(goaSource), `goa2 "generated.local/gen/goa"`) + logSource, err := os.ReadFile(filepath.Join(dir, "interceptors", "log_server.go")) + require.NoError(t, err) + require.Contains(t, string(logSource), `"goa.design/clue/log"`) + require.Contains(t, string(logSource), `log2 "generated.local/gen/log"`) + writeGeneratedModule(t, dir, "generated.local") + runGeneratedTests(t, dir) +} + +// TestTransportStaticAliasesCompileWithHttpAndPathServices verifies transport +// imports retain their literal qualifiers beside conflicting service names. +func TestTransportStaticAliasesCompileWithHttpAndPathServices(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + for _, name := range []string{"Http", "Path"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { dsl.POST("/" + strings.ToLower(name)) }) + dsl.GRPC(func() {}) + }) + dsl.Method("ReadJSON", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + } + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + httpServers, err := filepath.Glob(filepath.Join(genDir, "http", "*", "server", "server.go")) + require.NoError(t, err) + require.Len(t, httpServers, 2) + var httpSource strings.Builder + for _, server := range httpServers { + source, err := os.ReadFile(server) + require.NoError(t, err) + httpSource.Write(source) + } + require.Contains(t, httpSource.String(), `http_ "generated.local/gen/http_"`) + require.Contains(t, httpSource.String(), `path2 "generated.local/gen/path"`) + runGeneratedTests(t, genDir) +} + +// unusedRelocatedValueRoot declares a relocated type that no service reaches +// and does not force generation. It must not reserve a generated package name. +func unusedRelocatedValueRoot() func() { + return func() { + dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("unused", dsl.String) + }) + } +} + +// sharedRelocatedUnionRoot declares the same relocated union from two services +// so one generation-wide render emits its definition exactly once. +func sharedRelocatedUnionRoot() func() { + return func() { + first := relocatedValueType("FirstValue") + second := relocatedValueType("SecondValue") + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(first) + }) + }) + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(second) + }) + }) + } +} + +// relocatedValueType defines one force-generated owner of the shared Value +// union in the generated types package. +func relocatedValueType(name string) expr.UserType { + return dsl.Type(name, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("Value", func() { + dsl.Attribute("Bool", dsl.Boolean) + dsl.Attribute("Enum", dsl.String) + dsl.Attribute("Number", dsl.Float64) + }) + }) +} + +// relocatedTopLevelValueRoot declares an emitted top-level Value after the +// union definitions, so it receives the next available package-wide name. +func relocatedTopLevelValueRoot() func() { + return func() { + value := dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("text", dsl.String) + dsl.Required("text") + }) + dsl.Service("FifthService", func() { + dsl.Method("Read", func() { + dsl.Payload(value) + }) + }) + } +} + +// relocatedDifferentUnionRoot defines a union with the same natural name but +// a different branch shape, so it must receive the next package-wide name. +func relocatedDifferentUnionRoot() func() { + return func() { + value := dsl.Type("DifferentValue", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("Value", func() { + dsl.Attribute("Bool", dsl.Boolean) + dsl.Attribute("Text", dsl.String) + }) + }) + dsl.Service("FourthService", func() { + dsl.Method("Read", func() { + dsl.Payload(value) + }) + }) + } +} + +// relocatedUnionRoot defines one independently declared union with the same +// name and branch shape as the declarations in the other design roots. +func relocatedUnionRoot(typeName, serviceName string) func() { + return func() { + value := dsl.Type(typeName, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("Value", func() { + dsl.Attribute("Bool", dsl.Boolean) + dsl.Attribute("Enum", dsl.String) + dsl.Attribute("Number", dsl.Float64) + }) + }) + dsl.Service(serviceName, func() { + dsl.Method("Read", func() { + dsl.Payload(value) + }) + }) + } +} + +// unionFieldType returns the generated union type referenced by owner. +func unionFieldType(code, owner string) string { + prefix := "type " + owner + " struct {\n\tValue " + start := strings.Index(code, prefix) + if start == -1 { + return "" + } + start += len(prefix) + end := strings.IndexByte(code[start:], '\n') + if end == -1 { + return "" + } + return code[start : start+end] +} diff --git a/codegen/generator/test_helpers_test.go b/codegen/generator/test_helpers_test.go new file mode 100644 index 0000000000..37ab10cd66 --- /dev/null +++ b/codegen/generator/test_helpers_test.go @@ -0,0 +1,50 @@ +// This file builds complete generator plans for tests. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +// mustTestPlan chooses package names, finishes each selected generator, and +// fails the calling test if any step is invalid. +func mustTestPlan(t *testing.T, genpkg string, roots []eval.Root, planners ...func(*Plan) error) *Plan { + t.Helper() + generation, err := codegen.NewGeneration(genpkg, roots) + require.NoError(t, err) + plan := &Plan{ + generation: generation, + preparedRoots: roots, + examples: newExampleGenerators(roots), + } + for _, planner := range planners { + require.NoError(t, planner(plan)) + } + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.link()) + return plan +} + +// testServiceFiles returns service files from the plan under test. +func testServiceFiles(plan *Plan) ([]*codegen.File, error) { + return serviceFiles(plan) +} + +// testTransportFiles returns transport files from the plan under test. +func testTransportFiles(plan *Plan) ([]*codegen.File, error) { + return transportFiles(plan) +} + +// testOpenAPIFiles returns OpenAPI files from the plan under test. +func testOpenAPIFiles(plan *Plan) ([]*codegen.File, error) { + return openAPIFiles(plan) +} + +// assembleExampleFilesForTest returns example files from the plan under test. +func assembleExampleFilesForTest(plan *Plan) ([]*codegen.File, error) { + return exampleFiles(plan) +} diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index f69af44d50..8939b29ce6 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -1,8 +1,9 @@ +// This file builds the HTTP, gRPC, and JSON-RPC files for every service. Each +// generated file lists the Go packages that it uses. package generator import ( "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" grpccodegen "goa.design/goa/v3/grpc/codegen" @@ -10,51 +11,165 @@ import ( jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) -// Transport iterates through the roots and returns the files needed to render -// the transport code. +// Transport returns the HTTP, gRPC, and JSON-RPC files for roots. func Transport(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + return runStandaloneGenerator(genpkg, roots, transportGeneratorFactory) +} + +// transportFiles returns all HTTP, gRPC, and JSON-RPC files for one run. +func transportFiles(plan *Plan) ([]*codegen.File, error) { var files []*codegen.File - for _, root := range roots { - r, ok := root.(*expr.RootExpr) - if !ok { - continue // could be a plugin root expression + for _, transport := range plan.transports { + // HTTP + if httpPlan := transport.http; httpPlan != nil { + files = append(files, httpPlan.ServerFiles()...) + files = append(files, httpPlan.ClientFiles()...) + files = append(files, httpPlan.ServerTypeFiles()...) + files = append(files, httpPlan.ClientTypeFiles()...) + files = append(files, httpPlan.PathFiles()...) + files = append(files, httpPlan.ClientCLIFiles()...) } - // Create service data - services := service.NewServicesData(r) - for _, s := range r.Services { - service.SetUserTypeImports(genpkg, services.Get(s.Name)) + // GRPC + if grpcPlan := transport.grpc; grpcPlan != nil { + files = append(files, grpcPlan.ProtoFiles()...) + files = append(files, grpcPlan.ServerFiles()...) + files = append(files, grpcPlan.ClientFiles()...) + files = append(files, grpcPlan.ServerTypeFiles()...) + files = append(files, grpcPlan.ClientTypeFiles()...) + files = append(files, grpcPlan.ClientCLIFiles()...) } - // HTTP - httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) - files = append(files, httpcodegen.ServerFiles(genpkg, httpServices)...) - files = append(files, httpcodegen.ClientFiles(genpkg, httpServices)...) - files = append(files, httpcodegen.ServerTypeFiles(genpkg, httpServices)...) - files = append(files, httpcodegen.ClientTypeFiles(genpkg, httpServices)...) - files = append(files, httpcodegen.PathFiles(httpServices)...) - files = append(files, httpcodegen.ClientCLIFiles(genpkg, httpServices)...) + // JSON-RPC + if jsonrpcPlan := transport.jsonrpc; jsonrpcPlan != nil { + files = append(files, jsonrpcPlan.ServerFiles()...) + files = append(files, jsonrpcPlan.ClientFiles()...) + files = append(files, jsonrpcPlan.ServerTypeFiles()...) + files = append(files, jsonrpcPlan.ClientTypeFiles()...) + files = append(files, jsonrpcPlan.PathFiles()...) + files = append(files, jsonrpcPlan.ClientCLIFiles()...) + } + } + return files, nil +} - // GRPC - grpcServices := grpccodegen.NewServicesData(services) - files = append(files, grpccodegen.ProtoFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ServerFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ClientFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ServerTypeFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ClientTypeFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ClientCLIFiles(genpkg, grpcServices)...) +// planTransportData chooses all Go package, import, type, and function names +// before generated files use them. +func planTransportData(plan *Plan) error { + if err := planServiceData(plan); err != nil { + return err + } + if plan.transportDone { + return nil + } + generation := plan.Generation() + roots := serviceRoots(generation.Roots()) + if err := planHTTPTransports(plan, roots); err != nil { + return err + } + if err := planJSONRPCTransports(plan, roots); err != nil { + return err + } + var hasGRPC bool + for _, root := range roots { + hasGRPC = hasGRPC || len(root.API.GRPC.Services) > 0 + } + if hasGRPC { + var inputs []grpccodegen.PlanInput + var plannedRoots []*expr.RootExpr + for _, root := range roots { + if len(root.API.GRPC.Services) == 0 { + continue + } + inputs = append(inputs, grpccodegen.PlanInput{Root: root, Service: plan.Service(root)}) + plannedRoots = append(plannedRoots, root) + } + grpcPlans, err := grpccodegen.NewPlans(generation, inputs...) + if err != nil { + return err + } + plan.grpc = make(map[*expr.RootExpr]*grpccodegen.Plan, len(grpcPlans)) + for index, root := range plannedRoots { + plan.grpc[root] = grpcPlans[index] + } + } + plan.transports = make([]*transportPlanEntry, len(roots)) + for index, root := range roots { + plan.transports[index] = &transportPlanEntry{ + http: plan.http[root], + jsonrpcHTTP: plan.jsonrpcHTTP[root], + jsonrpc: plan.jsonrpc[root], + grpc: plan.grpc[root], + } + } + plan.transportDone = true + return nil +} - // JSON-RPC - jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &r.API.JSONRPC.HTTPExpr) - files = append(files, jsonrpccodegen.ServerFiles(genpkg, jsonrpcServices)...) - files = append(files, jsonrpccodegen.ClientFiles(genpkg, jsonrpcServices)...) - files = append(files, httpcodegen.ServerTypeFiles(genpkg, jsonrpcServices)...) - files = append(files, httpcodegen.ClientTypeFiles(genpkg, jsonrpcServices)...) - files = append(files, httpcodegen.PathFiles(jsonrpcServices)...) - files = append(files, httpcodegen.ClientCLIFiles(genpkg, jsonrpcServices)...) +// planHTTPTransports prepares every HTTP service together. When two services +// write to the same Go package, Goa gives their types and functions different names. +func planHTTPTransports(plan *Plan, roots []*expr.RootExpr) error { + var inputs []httpcodegen.PlanInput + var plannedRoots []*expr.RootExpr + for _, root := range roots { + if len(root.API.HTTP.Services) == 0 { + continue + } + inputs = append(inputs, httpcodegen.PlanInput{Root: root, Service: plan.Service(root)}) + plannedRoots = append(plannedRoots, root) + } + if len(inputs) == 0 { + return nil + } + plans, err := httpcodegen.NewPlans(plan.Generation(), inputs...) + if err != nil { + return err + } + plan.http = make(map[*expr.RootExpr]*httpcodegen.Plan, len(plans)) + for index, root := range plannedRoots { + plan.http[root] = plans[index] + } + return nil +} - // Add service data meta type imports - addServicesImports(files, services, r.Services) +// planJSONRPCTransports prepares the HTTP request and response types used by +// JSON-RPC. It then gives those values to the JSON-RPC generator so function +// definitions and calls use the same Go names. +func planJSONRPCTransports(plan *Plan, roots []*expr.RootExpr) error { + var inputs []httpcodegen.PlanInput + var plannedRoots []*expr.RootExpr + for _, root := range roots { + if len(root.API.JSONRPC.Services) == 0 { + continue + } + inputs = append(inputs, httpcodegen.PlanInput{Root: root, Service: plan.Service(root)}) + plannedRoots = append(plannedRoots, root) } - return files, nil + if len(inputs) == 0 { + return nil + } + httpPlans, err := httpcodegen.NewJSONRPCPlans(plan.Generation(), inputs...) + if err != nil { + return err + } + jsonrpcInputs := make([]jsonrpccodegen.PlanInput, len(inputs)) + plan.jsonrpcHTTP = make(map[*expr.RootExpr]*httpcodegen.Plan, len(httpPlans)) + for index, root := range plannedRoots { + plan.jsonrpcHTTP[root] = httpPlans[index] + jsonrpcInputs[index] = jsonrpccodegen.PlanInput{ + Root: root, + Service: plan.Service(root), + HTTP: httpPlans[index], + ApplicationHTTP: plan.http[root], + } + } + jsonrpcPlans, err := jsonrpccodegen.NewPlans(plan.Generation(), jsonrpcInputs...) + if err != nil { + return err + } + plan.jsonrpc = make(map[*expr.RootExpr]*jsonrpccodegen.Plan, len(jsonrpcPlans)) + for index, root := range plannedRoots { + plan.jsonrpc[root] = jsonrpcPlans[index] + } + return nil } diff --git a/codegen/generator/transport_plan_test.go b/codegen/generator/transport_plan_test.go new file mode 100644 index 0000000000..3cd8f74dbc --- /dev/null +++ b/codegen/generator/transport_plan_test.go @@ -0,0 +1,38 @@ +// This file checks that plugins can find the gRPC and JSON-RPC plans retained +// for the exact prepared design root they received. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" + grpccodegen "goa.design/goa/v3/grpc/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" +) + +func TestTransportPlansUseExactRoot(t *testing.T) { + grpcRoot := &expr.RootExpr{} + jsonrpcRoot := &expr.RootExpr{} + grpcPlan := &grpccodegen.Plan{} + jsonrpcPlan := &jsonrpccodegen.Plan{} + plan := &Plan{ + grpc: map[*expr.RootExpr]*grpccodegen.Plan{grpcRoot: grpcPlan}, + jsonrpc: map[*expr.RootExpr]*jsonrpccodegen.Plan{jsonrpcRoot: jsonrpcPlan}, + } + + gotGRPC, ok := plan.GRPC(grpcRoot) + require.True(t, ok) + require.Same(t, grpcPlan, gotGRPC) + gotGRPC, ok = plan.GRPC(&expr.RootExpr{}) + require.False(t, ok) + require.Nil(t, gotGRPC) + + gotJSONRPC, ok := plan.JSONRPC(jsonrpcRoot) + require.True(t, ok) + require.Same(t, jsonrpcPlan, gotJSONRPC) + gotJSONRPC, ok = plan.JSONRPC(&expr.RootExpr{}) + require.False(t, ok) + require.Nil(t, gotJSONRPC) +} diff --git a/codegen/generator/viewed_transport_import_integration_test.go b/codegen/generator/viewed_transport_import_integration_test.go new file mode 100644 index 0000000000..3c211d043e --- /dev/null +++ b/codegen/generator/viewed_transport_import_integration_test.go @@ -0,0 +1,219 @@ +// This file verifies that transport client files import generated views only +// when one of their emitted response or stream-receive sections references it. +package generator + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestViewedTransportClientImportsCompile verifies that HTTP and JSON-RPC +// response decoders and gRPC response and stream decoders receive the exact +// generated views import used by their rendered sections. The unary gRPC +// client also proves client.go does not reserve the codec-only import. +func TestViewedTransportClientImportsCompile(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.viewed-import", func() { + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { + dsl.Attribute("value") + }) + }) + dsl.Service("ViewedHTTPJSON", func() { + dsl.Method("HTTP", func() { + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET("/http") + }) + }) + dsl.Method("JSONRPC", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) + dsl.Service("ViewedUnary", func() { + dsl.Method("GRPCUnary", func() { + dsl.Result(result) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("ViewedStream", func() { + dsl.Method("GRPCStream", func() { + dsl.StreamingResult(result) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("ViewedHTTPSSE", func() { + dsl.Method("Events", func() { + dsl.StreamingResult(result) + dsl.HTTP(func() { + dsl.GET("/http-sse") + dsl.ServerSentEvents("value") + }) + }) + }) + dsl.Service("ViewedHTTPWebSocket", func() { + dsl.Method("Events", func() { + dsl.StreamingResult(result) + dsl.HTTP(func() { + dsl.GET("/http-websocket") + }) + }) + }) + dsl.Service("Ordinary", func() { + dsl.Method("HTTP", func() { + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.GET("/ordinary") + }) + }) + dsl.Method("JSONRPC", func() { + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + dsl.Method("GRPCUnary", func() { + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + dsl.Method("GRPCStream", func() { + dsl.StreamingResult(dsl.String) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + httpJSON := codegen.SnakeCase("ViewedHTTPJSON") + unary := codegen.SnakeCase("ViewedUnary") + stream := codegen.SnakeCase("ViewedStream") + assertFilesImportPath(t, genDir, "/"+httpJSON+"/views\"", []string{ + filepath.Join("http", httpJSON, "client", "encode_decode.go"), + filepath.Join("http", httpJSON, "client", "types.go"), + filepath.Join("http", httpJSON, "server", "encode_decode.go"), + filepath.Join("http", httpJSON, "server", "types.go"), + filepath.Join("jsonrpc", httpJSON, "client", "encode_decode.go"), + filepath.Join("jsonrpc", httpJSON, "client", "types.go"), + filepath.Join("jsonrpc", httpJSON, "server", "server.go"), + filepath.Join("jsonrpc", httpJSON, "server", "types.go"), + }) + assertNoImportPath(t, filepath.Join(genDir, "grpc", unary, "client", "client.go"), "/"+unary+"/views\"") + assertFilesImportPath(t, genDir, "/"+unary+"/views\"", []string{ + filepath.Join("grpc", unary, "client", "encode_decode.go"), + filepath.Join("grpc", unary, "client", "types.go"), + filepath.Join("grpc", unary, "server", "encode_decode.go"), + filepath.Join("grpc", unary, "server", "types.go"), + }) + assertFilesImportPath(t, genDir, "/"+stream+"/views\"", []string{ + filepath.Join("grpc", stream, "client", "client.go"), + filepath.Join("grpc", stream, "client", "types.go"), + filepath.Join("grpc", stream, "server", "encode_decode.go"), + filepath.Join("grpc", stream, "server", "types.go"), + }) + assertNoImportPath(t, filepath.Join(genDir, "grpc", stream, "client", "encode_decode.go"), "/"+stream+"/views\"") + assertViewedStreamingTransportFiles(t, genDir) + ordinary := codegen.SnakeCase("Ordinary") + assertFilesOmitImportPath(t, genDir, "/"+ordinary+"/views\"", []string{ + filepath.Join("http", ordinary, "client", "encode_decode.go"), + filepath.Join("http", ordinary, "client", "types.go"), + filepath.Join("http", ordinary, "server", "encode_decode.go"), + filepath.Join("http", ordinary, "server", "types.go"), + filepath.Join("jsonrpc", ordinary, "client", "encode_decode.go"), + filepath.Join("jsonrpc", ordinary, "client", "types.go"), + filepath.Join("jsonrpc", ordinary, "server", "server.go"), + filepath.Join("jsonrpc", ordinary, "server", "types.go"), + filepath.Join("grpc", ordinary, "client", "client.go"), + filepath.Join("grpc", ordinary, "client", "encode_decode.go"), + filepath.Join("grpc", ordinary, "client", "types.go"), + filepath.Join("grpc", ordinary, "server", "encode_decode.go"), + filepath.Join("grpc", ordinary, "server", "types.go"), + }) + runGeneratedTests(t, genDir) +} + +// assertViewedStreamingTransportFiles checks the generated HTTP SSE and +// WebSocket files. Client files import the views package to validate each result +// after rebuilding it. Server files use the service constructor, which performs +// that validation without a direct views import. +func assertViewedStreamingTransportFiles(t *testing.T, genDir string) { + t.Helper() + httpSSE := codegen.SnakeCase("ViewedHTTPSSE") + httpWebSocket := codegen.SnakeCase("ViewedHTTPWebSocket") + for _, path := range []string{ + filepath.Join("http", httpSSE, "server", "sse.go"), + filepath.Join("http", httpSSE, "client", "sse.go"), + filepath.Join("http", httpWebSocket, "server", "websocket.go"), + } { + require.FileExists(t, filepath.Join(genDir, path)) + } + assertImportPath( + t, + filepath.Join(genDir, "http", httpWebSocket, "client", "websocket.go"), + "/"+httpWebSocket+"/views\"", + ) + assertImportPath( + t, + filepath.Join(genDir, "http", httpSSE, "client", "sse.go"), + "/"+httpSSE+"/views\"", + ) + assertFilesOmitImportPath(t, genDir, "/"+httpSSE+"/views\"", []string{ + filepath.Join("http", httpSSE, "server", "sse.go"), + }) + assertNoImportPath( + t, + filepath.Join(genDir, "http", httpWebSocket, "server", "websocket.go"), + "/"+httpWebSocket+"/views\"", + ) +} + +// assertFilesImportPath verifies that each generated file imports a package +// whose full path ends with suffix. +func assertFilesImportPath(t *testing.T, root, suffix string, paths []string) { + t.Helper() + for _, path := range paths { + assertImportPath(t, filepath.Join(root, path), suffix) + } +} + +// assertFilesOmitImportPath verifies that none of the generated files reserve +// the package whose full path ends with suffix. +func assertFilesOmitImportPath(t *testing.T, root, suffix string, paths []string) { + t.Helper() + for _, path := range paths { + assertNoImportPath(t, filepath.Join(root, path), suffix) + } +} + +// assertImportPath verifies that a generated file imports a package whose full +// path ends with suffix. +func assertImportPath(t *testing.T, path, suffix string) { + t.Helper() + content, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(content), suffix) +} + +// assertNoImportPath verifies that a generated file does not reserve an import +// for a package unused by its rendered sections. +func assertNoImportPath(t *testing.T, path, suffix string) { + t.Helper() + content, err := os.ReadFile(path) + require.NoError(t, err) + require.False(t, strings.Contains(string(content), suffix), string(content)) +} diff --git a/codegen/generator/viewed_transport_representation_integration_test.go b/codegen/generator/viewed_transport_representation_integration_test.go new file mode 100644 index 0000000000..c7edeef636 --- /dev/null +++ b/codegen/generator/viewed_transport_representation_integration_test.go @@ -0,0 +1,235 @@ +// This file checks that generated clients and servers send each result view +// with the JSON fields selected by that view. +package generator + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestGeneratedHTTPViewedSSEServerUsesRequestView checks that an SSE request +// uses the view selected by the service call. A method with one fixed view does +// not choose a view while it runs. +func TestGeneratedHTTPViewedSSEServerUsesRequestView(t *testing.T) { + dir := generateViewedTransportModule(t, viewedHTTPSSEDSL) + writeGeneratedContractTest(t, dir, filepath.Join("http", "http_view_stream", "server"), httpViewedSSEServerTest) + runGeneratedPackageTests(t, dir, "./http/http_view_stream/server") +} + +// TestGeneratedHTTPViewedSSEClientRebuildsResult checks that an SSE client +// reads the selected HTTP body before rebuilding the service result, including +// JSON field names and nested fields. +func TestGeneratedHTTPViewedSSEClientRebuildsResult(t *testing.T) { + dir := generateViewedTransportModule(t, viewedHTTPSSEDSL) + writeGeneratedContractTest(t, dir, filepath.Join("http", "http_view_stream", "client"), httpViewedSSEClientTest) + runGeneratedPackageTests(t, dir, "./http/http_view_stream/client") +} + +// TestGeneratedJSONRPCUnaryViewedRepresentation checks that a one-result call +// sends both the selected view name and its JSON body. The view name must not +// come from an HTTP header. +func TestGeneratedJSONRPCUnaryViewedRepresentation(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCUnaryDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpc_unary", "client"), jsonRPCViewedUnaryClientTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpc_unary/client") +} + +// TestGeneratedJSONRPCUnaryServerEmitsViewedRepresentation checks that the +// server writes the selected view and matching body in the JSON-RPC result. A +// method with one fixed view writes only the body. +func TestGeneratedJSONRPCUnaryServerEmitsViewedRepresentation(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCUnaryDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpc_unary", "server"), jsonRPCViewedUnaryServerTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpc_unary/server") +} + +// TestGeneratedJSONRPCViewedServiceNameCompiles checks that the selected-view +// value does not hide the generated service package with the same Go name. +func TestGeneratedJSONRPCViewedServiceNameCompiles(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCQualifierCollisionDSL) + runGeneratedPackageTests(t, dir, "./jsonrpc/viewed/client") +} + +// TestGeneratedJSONRPCSSEViewedRepresentation checks that JSON-RPC SSE pairs +// every view name with its matching body and rebuilds service results from +// notifications before the terminal response ends the stream. +func TestGeneratedJSONRPCSSEViewedRepresentation(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCSSEDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpcsse", "client"), jsonRPCViewedSSEClientTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpcsse/client") +} + +// TestGeneratedJSONRPCSSEServerEmitsViewedRepresentation checks that SSE +// notifications contain the same view name and body that clients read. +// Methods with one fixed view contain only the body. +func TestGeneratedJSONRPCSSEServerEmitsViewedRepresentation(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCSSEDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpcsse", "server"), jsonRPCViewedSSEServerTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpcsse/server") +} + +// generateViewedTransportModule generates a temporary Go module for one test. +func generateViewedTransportModule(t *testing.T, design func()) string { + t.Helper() + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + codegen.RunDSL(t, design) + dir := filepath.Join(t.TempDir(), codegen.Gendir) + writeGeneratedModule(t, dir, "generated.local/gen") + _, err := generate(filepath.Dir(dir), "gen", false, registry) + require.NoError(t, err) + return dir +} + +// writeGeneratedContractTest adds a test that calls one generated package. +// The generated module is temporary; the source tree remains untouched. +func writeGeneratedContractTest(t *testing.T, moduleDir, packageDir, source string) { + t.Helper() + dir := filepath.Join(moduleDir, packageDir) + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "viewed_contract_test.go"), []byte(source), 0o600)) +} + +// runGeneratedPackageTests compiles and runs one generated package. Limiting +// the command to that package makes a failure point to the code under test. +func runGeneratedPackageTests(t *testing.T, dir, packagePattern string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", packagePattern) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("test generated package %s: %v\n%s", packagePattern, err, output) + } +} + +// viewedResultType defines a result whose selected view changes both the JSON +// body fields and their JSON names. +func viewedResultType() *expr.ResultTypeExpr { + profile := dsl.Type("Profile", func() { + dsl.Attribute("display_name", dsl.String) + dsl.Required("display_name") + }) + return dsl.ResultType("application/vnd.viewed-event", func() { + dsl.TypeName("Event") + dsl.Attribute("event_id", dsl.String) + dsl.Attribute("profile", profile) + dsl.Required("event_id", "profile") + dsl.View("summary", func() { + dsl.Attribute("event_id") + }) + dsl.View("detailed", func() { + dsl.Attribute("event_id") + dsl.Attribute("profile") + }) + }) +} + +// viewedHTTPSSEDSL creates HTTP SSE methods with selectable and fixed views. +func viewedHTTPSSEDSL() { + event := viewedResultType() + immediate := dsl.Type("Immediate", func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Service("HTTP View Stream", func() { + dsl.Method("watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + dsl.Method("fixed", func() { + dsl.StreamingResult(event, func() { + dsl.View("detailed") + }) + dsl.HTTP(func() { + dsl.GET("/fixed") + dsl.ServerSentEvents() + }) + }) + dsl.Method("mixed", func() { + dsl.Result(immediate) + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/mixed") + dsl.ServerSentEvents() + }) + }) + }) +} + +// viewedJSONRPCUnaryDSL creates one-result JSON-RPC methods with selectable +// and fixed views. +func viewedJSONRPCUnaryDSL() { + event := viewedResultType() + dsl.Service("JSON RPC Unary", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("fetch", func() { + dsl.Result(event) + dsl.JSONRPC(func() {}) + }) + dsl.Method("fixed", func() { + dsl.Result(event, func() { + dsl.View("detailed") + }) + dsl.JSONRPC(func() {}) + }) + }) +} + +// viewedJSONRPCQualifierCollisionDSL uses the service name that previously +// matched the local selected-view value in the generated decoder. +func viewedJSONRPCQualifierCollisionDSL() { + event := viewedResultType() + dsl.Service("viewed", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("fetch", func() { + dsl.Result(event) + dsl.JSONRPC(func() {}) + }) + }) +} + +// viewedJSONRPCSSEDSL creates JSON-RPC SSE methods with selectable and fixed +// views. +func viewedJSONRPCSSEDSL() { + event := viewedResultType() + dsl.Service("JSON RPC SSE", func() { + dsl.JSONRPC(func() { + dsl.POST("/events") + }) + dsl.Method("watch", func() { + dsl.StreamingResult(event) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + dsl.Method("fixed", func() { + dsl.StreamingResult(event, func() { + dsl.View("detailed") + }) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + }) +} diff --git a/codegen/generator/viewed_transport_runtime_sources_test.go b/codegen/generator/viewed_transport_runtime_sources_test.go new file mode 100644 index 0000000000..5fb8cfaba5 --- /dev/null +++ b/codegen/generator/viewed_transport_runtime_sources_test.go @@ -0,0 +1,923 @@ +// This file contains source code that calls generated HTTP and JSON-RPC code +// with result views. Each source string runs in a temporary Go module. +package generator + +const httpViewedSSEServerTest = `package server + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/http_view_stream" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type viewedService struct { + serviceDefaults + watchView string +} + +type unknownViewService struct { + serviceDefaults + sendError chan error +} + +type changingViewService struct { + serviceDefaults + sendError chan error +} + +type sendResultService struct { + serviceDefaults + sendError chan error +} + +type fixedErrorAfterEventService struct{ serviceDefaults } + +type mixedErrorAfterEventService struct{ serviceDefaults } + +type serviceDefaults struct{} + +type statusRecorder struct { + *httptest.ResponseRecorder + statuses []int +} + +type streamResponseWriter struct { + header http.Header + status int + body strings.Builder + writeError error +} + +var errEventWrite = errors.New("event write failed") +var errAfterEvent = errors.New("service failed after event") + +func (serviceDefaults) Watch(_ context.Context, _ service.WatchServerStream) error { + return nil +} + +func (serviceDefaults) Fixed(_ context.Context, _ service.FixedServerStream) error { + return nil +} + +func (serviceDefaults) Mixed(_ context.Context, _ service.MixedServerStream) (*service.Immediate, error) { + return nil, nil +} + +func (s *viewedService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView(s.watchView) + return stream.Send(viewedEvent()) +} + +func (s *viewedService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (s *unknownViewService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("unknown") + err := stream.Send(viewedEvent()) + s.sendError <- err + return err +} + +func (*unknownViewService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (s *changingViewService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("summary") + if err := stream.Send(viewedEvent()); err != nil { + return err + } + stream.SetView("detailed") + err := stream.Send(viewedEvent()) + s.sendError <- err + return nil +} + +func (*changingViewService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (s *sendResultService) Watch(_ context.Context, stream service.WatchServerStream) error { + err := stream.Send(viewedEvent()) + s.sendError <- err + return err +} + +func (*sendResultService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (*fixedErrorAfterEventService) Watch(_ context.Context, stream service.WatchServerStream) error { + return stream.Send(viewedEvent()) +} + +func (*fixedErrorAfterEventService) Fixed(_ context.Context, stream service.FixedServerStream) error { + if err := stream.Send(viewedEvent()); err != nil { + return err + } + return errAfterEvent +} + +func (*mixedErrorAfterEventService) Mixed(_ context.Context, stream service.MixedServerStream) (*service.Immediate, error) { + if err := stream.Send(viewedEvent()); err != nil { + return nil, err + } + return nil, errAfterEvent +} + +func (w *statusRecorder) WriteHeader(status int) { + w.statuses = append(w.statuses, status) + w.ResponseRecorder.WriteHeader(status) +} + +func (w *streamResponseWriter) Header() http.Header { + return w.header +} + +func (w *streamResponseWriter) WriteHeader(status int) { + w.status = status +} + +func (w *streamResponseWriter) Write(data []byte) (int, error) { + if w.writeError != nil { + return 0, w.writeError + } + return w.body.Write(data) +} + +func TestViewedSSEServerUsesRequestView(t *testing.T) { + svc := &viewedService{watchView: "detailed"} + handler := NewWatchHandler( + service.NewWatchEndpoint(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", "/watch", nil)) + require.Equal(t, "detailed", recorder.Header().Get("goa-view")) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + sseData(t, recorder.Body.String()), + ) +} + +func TestFixedViewedSSEServerIsSpecialized(t *testing.T) { + _, exposesSetView := reflect.TypeOf((*service.FixedServerStream)(nil)).Elem().MethodByName("SetView") + require.False(t, exposesSetView) + + svc := &viewedService{} + handler := NewFixedHandler( + service.NewFixedEndpoint(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", "/fixed", nil)) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + sseData(t, recorder.Body.String()), + ) +} + +func TestFixedViewedSSEServerDoesNotEncodeServiceErrorAfterEvent(t *testing.T) { + var handled error + handler := NewFixedHandler( + service.NewFixedEndpoint(&fixedErrorAfterEventService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { handled = err }, + nil, + ) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", "/fixed", nil)) + require.ErrorIs(t, handled, errAfterEvent) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "data:")) + require.NotContains(t, recorder.Body.String(), ` + "`" + `"name":` + "`" + `) +} + +func TestMixedResultSSEServerDoesNotEncodeServiceErrorAfterEvent(t *testing.T) { + var handled error + handler := NewMixedHandler( + service.NewMixedEndpoint(&mixedErrorAfterEventService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { handled = err }, + nil, + ) + request := httptest.NewRequest("GET", "/mixed", nil) + request.Header.Set("Accept", "text/event-stream") + recorder := &statusRecorder{ResponseRecorder: httptest.NewRecorder()} + handler.ServeHTTP(recorder, request) + require.ErrorIs(t, handled, errAfterEvent) + require.Equal(t, []int{http.StatusOK}, recorder.statuses) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "data:")) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + sseData(t, recorder.Body.String()), + ) + require.NotContains(t, recorder.Body.String(), ` + "`" + `"name":` + "`" + `) +} + +func TestUnknownViewedSSEServerSelectionIsRejectedBeforeWriting(t *testing.T) { + sendError := make(chan error, 1) + handler := NewWatchHandler( + service.NewWatchEndpoint(&unknownViewService{sendError: sendError}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + server := httptest.NewServer(handler) + defer server.Close() + response, err := server.Client().Get(server.URL + "/watch") + require.NoError(t, err) + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") + require.Equal(t, http.StatusBadRequest, response.StatusCode) + var serviceError map[string]any + require.NoError(t, json.Unmarshal(body, &serviceError)) + require.Equal(t, goa.InvalidEnumValue, serviceError["name"]) + require.Contains(t, serviceError["message"], "value of view") +} + +func TestEmptyViewedSSEServerSelectionUsesDefaultView(t *testing.T) { + handler := NewWatchHandler( + service.NewWatchEndpoint(&viewedService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + server := httptest.NewServer(handler) + defer server.Close() + response, err := server.Client().Get(server.URL + "/watch") + require.NoError(t, err) + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, "default", response.Header.Get("goa-view")) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + sseData(t, string(body)), + ) +} + +func TestViewedSSEServerRejectsViewChangesAfterFirstEvent(t *testing.T) { + sendError := make(chan error, 1) + handler := NewWatchHandler( + service.NewWatchEndpoint(&changingViewService{sendError: sendError}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", "/watch", nil)) + requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") + require.Equal(t, "summary", recorder.Header().Get("goa-view")) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "data:")) +} + +func TestViewedSSEServerReturnsEventWriteError(t *testing.T) { + sendError := make(chan error, 1) + handler := NewWatchHandler( + service.NewWatchEndpoint(&sendResultService{sendError: sendError}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + writer := &streamResponseWriter{header: make(http.Header), writeError: errEventWrite} + handler.ServeHTTP(writer, httptest.NewRequest("GET", "/watch", nil)) + require.ErrorIs(t, <-sendError, errEventWrite) + require.Equal(t, http.StatusOK, writer.status) +} + +func TestViewedSSEServerReturnsFlushError(t *testing.T) { + sendError := make(chan error, 1) + handler := NewWatchHandler( + service.NewWatchEndpoint(&sendResultService{sendError: sendError}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + writer := &streamResponseWriter{header: make(http.Header)} + handler.ServeHTTP(writer, httptest.NewRequest("GET", "/watch", nil)) + require.ErrorIs(t, <-sendError, http.ErrNotSupported) + require.Equal(t, http.StatusOK, writer.status) + require.NotEmpty(t, writer.body.String()) +} + +func viewedEvent() *service.Event { + return &service.Event{ + EventID: "event-1", + Profile: &service.Profile{DisplayName: "Ada"}, + } +} + +func sseData(t *testing.T, event string) string { + t.Helper() + for _, line := range strings.Split(event, "\n") { + if strings.HasPrefix(line, "data:") { + return strings.TrimSpace(strings.TrimPrefix(line, "data:")) + } + } + t.Errorf("SSE event has no data field: %q", event) + return "" +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const httpViewedSSEClientTest = `package client + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/http_view_stream" + goahttp "goa.design/goa/v3/http" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestViewedSSEClientReconstructsSelectedBody(t *testing.T) { + cases := []struct { + name string + endpoint func(*Client) func(context.Context, any) (any, error) + recv func(any) (*service.Event, error) + view string + body string + wantProfile bool + }{ + { + name: "summary", + endpoint: func(c *Client) func(context.Context, any) (any, error) { return c.Watch() }, + recv: func(raw any) (*service.Event, error) { + var stream service.WatchClientStream = raw.(WatchClientStream) + return stream.Recv() + }, + view: "summary", + body: ` + "`" + `{"event_id":"summary-event"}` + "`" + `, + }, + { + name: "detailed fixed", + endpoint: func(c *Client) func(context.Context, any) (any, error) { return c.Fixed() }, + recv: func(raw any) (*service.Event, error) { + var stream service.FixedClientStream = raw.(FixedClientStream) + return stream.Recv() + }, + body: ` + "`" + `{"event_id":"detailed-event","profile":{"display_name":"Ada"}}` + "`" + `, + wantProfile: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + doer := doerFunc(func(*http.Request) (*http.Response, error) { + header := http.Header{"Content-Type": []string{"text/event-stream"}} + if tc.view != "" { + header.Set("goa-view", tc.view) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: header, + Body: io.NopCloser(strings.NewReader("data: " + tc.body + "\n\n")), + }, nil + }) + client := NewClient( + "http", "example.test", doer, + goahttp.RequestEncoder, goahttp.ResponseDecoder, false, + ) + rawStream, err := tc.endpoint(client)(context.Background(), nil) + require.NoError(t, err) + event, err := tc.recv(rawStream) + require.NoError(t, err) + require.Equal(t, strings.TrimSuffix(tc.name, " fixed")+"-event", event.EventID) + if tc.wantProfile { + require.Equal(t, "Ada", event.Profile.DisplayName) + } else { + require.Nil(t, event.Profile) + } + }) + } +} +` + +const jsonRPCViewedUnaryClientTest = `package client + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpc_unary" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func TestVariableViewedUnaryResponseUsesRepresentationBody(t *testing.T) { + response := jsonRPCResponse( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":{"view":"detailed","body":{"event_id":"event-1","profile":{"display_name":"Ada"}}}}` + "`" + `, + ) + response.Header.Set("goa-view", "summary") + var result any + var err error + require.NotPanics(t, func() { + result, err = DecodeFetchResponse(goahttp.ResponseDecoder, false)(response) + }) + require.NoError(t, err) + event := result.(*service.Event) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "Ada", event.Profile.DisplayName) +} + +func TestVariableViewedUnaryResponseRejectsInvalidRepresentation(t *testing.T) { + cases := []struct { + name string + result string + errorName string + field string + }{ + {"missing view", ` + "`" + `{"body":{"event_id":"event-1"}}` + "`" + `, goa.MissingField, "view"}, + {"null view", ` + "`" + `{"view":null,"body":{"event_id":"event-1"}}` + "`" + `, goa.MissingField, "view"}, + {"missing body", ` + "`" + `{"view":"summary"}` + "`" + `, goa.MissingField, "body"}, + {"null body", ` + "`" + `{"view":"summary","body":null}` + "`" + `, goa.MissingField, "body"}, + {"unknown view", ` + "`" + `{"view":"unknown","body":{"event_id":"event-1"}}` + "`" + `, goa.InvalidEnumValue, "view"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + response := jsonRPCResponse(` + "`" + `{"jsonrpc":"2.0","id":"1","result":` + "`" + ` + tc.result + "}") + var err error + require.NotPanics(t, func() { + _, err = DecodeFetchResponse(goahttp.ResponseDecoder, false)(response) + }) + requireBoundaryError(t, err, tc.errorName, tc.field) + }) + } +} + +func TestFixedViewedUnaryResponseUsesBodyOnly(t *testing.T) { + response := jsonRPCResponse( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":{"event_id":"event-1","profile":{"display_name":"Ada"}}}` + "`" + `, + ) + result, err := DecodeFixedResponse(goahttp.ResponseDecoder, false)(response) + require.NoError(t, err) + event := result.(*service.Event) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "Ada", event.Profile.DisplayName) +} + +func jsonRPCResponse(body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const jsonRPCViewedUnaryServerTest = `package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpc_unary" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type viewedService struct { + fetchView string +} + +func (s *viewedService) Fetch(context.Context) (*service.Event, string, error) { + return viewedEvent(), s.fetchView, nil +} + +func (*viewedService) Fixed(context.Context) (*service.Event, error) { + return viewedEvent(), nil +} + +func TestVariableViewedUnaryServerEmitsRepresentation(t *testing.T) { + recorder := serveJSONRPC(t, "fetch", "detailed") + require.Empty(t, recorder.Header().Get("goa-view")) + require.JSONEq(t, + ` + "`" + `{"view":"detailed","body":{"event_id":"event-1","profile":{"display_name":"Ada"}}}` + "`" + `, + jsonRPCResult(t, recorder), + ) +} + +func TestFixedViewedUnaryServerEmitsBodyOnly(t *testing.T) { + recorder := serveJSONRPC(t, "fixed", "") + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + jsonRPCResult(t, recorder), + ) +} + +func TestUnknownViewedUnaryServerSelectionIsRejected(t *testing.T) { + result, err := service.NewFetchEndpoint(&viewedService{fetchView: "unknown"})(context.Background(), nil) + require.Nil(t, result) + requireBoundaryError(t, err, goa.InvalidEnumValue, "view") +} + +func serveJSONRPC(t *testing.T, method, view string) *httptest.ResponseRecorder { + t.Helper() + server := New( + service.NewEndpoints(&viewedService{fetchView: view}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + ) + body := []byte(` + "`" + `{"jsonrpc":"2.0","id":"1","method":"` + "`" + ` + method + ` + "`" + `"}` + "`" + `) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("POST", "/rpc", bytes.NewReader(body))) + require.Equal(t, http.StatusOK, recorder.Code) + return recorder +} + +func jsonRPCResult(t *testing.T, recorder *httptest.ResponseRecorder) string { + t.Helper() + var response struct { + Result json.RawMessage ` + "`" + `json:"result"` + "`" + ` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + return string(response.Result) +} + +func viewedEvent() *service.Event { + return &service.Event{ + EventID: "event-1", + Profile: &service.Profile{DisplayName: "Ada"}, + } +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const jsonRPCViewedSSEClientTest = `package client + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpcsse" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestViewedSSENotificationReconstructsTransportBody(t *testing.T) { + data := ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"detailed","body":{"event_id":"event-1","profile":{"display_name":"Ada"}}}}` + "`" + ` + event, err := recvWatch("notification", data) + require.NoError(t, err) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "Ada", event.Profile.DisplayName) +} + +func TestViewedSSEFinalResponseEndsStream(t *testing.T) { + data := ` + "`" + `{"jsonrpc":"2.0","id":"1","result":null}` + "`" + ` + _, err := recvWatch("response", data) + require.ErrorIs(t, err, io.EOF) +} + +func TestViewedSSERejectsInvalidRepresentation(t *testing.T) { + cases := []struct { + name string + params string + errorName string + field string + }{ + {"missing view", ` + "`" + `{"body":{"event_id":"event-1"}}` + "`" + `, goa.MissingField, "view"}, + {"null view", ` + "`" + `{"view":null,"body":{"event_id":"event-1"}}` + "`" + `, goa.MissingField, "view"}, + {"missing body", ` + "`" + `{"view":"summary"}` + "`" + `, goa.MissingField, "body"}, + {"null body", ` + "`" + `{"view":"summary","body":null}` + "`" + `, goa.MissingField, "body"}, + {"unknown view", ` + "`" + `{"view":"unknown","body":{"event_id":"event-1"}}` + "`" + `, goa.InvalidEnumValue, "view"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + data := ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":` + "`" + ` + tc.params + "}" + _, err := recvWatch("notification", data) + requireBoundaryError(t, err, tc.errorName, tc.field) + }) + } +} + +func TestFixedViewedSSEUsesBodyOnly(t *testing.T) { + data := ` + "`" + `{"jsonrpc":"2.0","method":"fixed","params":{"event_id":"event-1","profile":{"display_name":"Ada"}}}` + "`" + ` + event, err := recvFixed("notification", data) + require.NoError(t, err) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "Ada", event.Profile.DisplayName) +} + +func recvWatch(eventType, data string) (*service.Event, error) { + client := sseClient(eventType, data) + raw, err := client.Watch()(context.Background(), nil) + if err != nil { + return nil, err + } + transport := raw.(*WatchStreamImpl) + var stream service.WatchClientStream = transport + return stream.Recv() +} + +func recvFixed(eventType, data string) (*service.Event, error) { + client := sseClient(eventType, data) + raw, err := client.Fixed()(context.Background(), nil) + if err != nil { + return nil, err + } + transport := raw.(*FixedStreamImpl) + var stream service.FixedClientStream = transport + return stream.Recv() +} + +func sseClient(eventType, data string) *Client { + doer := doerFunc(func(*http.Request) (*http.Response, error) { + body := "event: " + eventType + "\ndata: " + data + "\n\n" + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }) + return NewClient( + "http", "example.test", doer, + goahttp.RequestEncoder, goahttp.ResponseDecoder, false, + ) +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const jsonRPCViewedSSEServerTest = `package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpcsse" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type viewedService struct{} + +type unknownViewService struct { + sendError chan error +} + +type changedViewService struct { + sendError chan error +} + +func (*viewedService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("summary") + if err := stream.Send(viewedEvent()); err != nil { + return err + } + return stream.Send(viewedEvent()) +} + +func (*viewedService) Fixed(_ context.Context, stream service.FixedServerStream) error { + if err := stream.Send(viewedEvent()); err != nil { + return err + } + return stream.Send(viewedEvent()) +} + +func (s *unknownViewService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("unknown") + err := stream.Send(viewedEvent()) + s.sendError <- err + return err +} + +func (*unknownViewService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (s *changedViewService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("summary") + if err := stream.Send(viewedEvent()); err != nil { + return err + } + stream.SetView("detailed") + err := stream.Send(viewedEvent()) + s.sendError <- err + return err +} + +func (*changedViewService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func TestVariableViewedSSEServerEmitsRepresentation(t *testing.T) { + recorder := serveSSE(t, "watch") + records := jsonRPCSSERecords(t, recorder.Body.String()) + require.Len(t, records, 3) + require.JSONEq(t, + ` + "`" + `{"view":"summary","body":{"event_id":"event-1"}}` + "`" + `, + string(records[0].Params), + ) + require.JSONEq(t, + ` + "`" + `{"view":"summary","body":{"event_id":"event-1"}}` + "`" + `, + string(records[1].Params), + ) + require.JSONEq(t, ` + "`" + `null` + "`" + `, string(records[2].Result)) +} + +func TestChangedViewedSSEServerSelectionIsRejectedBeforeWriting(t *testing.T) { + sendError := make(chan error, 1) + recorder := serveSSEService(t, "watch", &changedViewService{sendError: sendError}) + requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") + records := jsonRPCSSERecords(t, recorder.Body.String()) + require.Len(t, records, 2) + require.JSONEq(t, + ` + "`" + `{"view":"summary","body":{"event_id":"event-1"}}` + "`" + `, + string(records[0].Params), + ) + require.NotEmpty(t, records[1].Error) +} + +func TestFixedViewedSSEServerEmitsBodyOnly(t *testing.T) { + recorder := serveSSE(t, "fixed") + records := jsonRPCSSERecords(t, recorder.Body.String()) + require.Len(t, records, 3) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + string(records[0].Params), + ) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + string(records[1].Params), + ) + require.JSONEq(t, ` + "`" + `null` + "`" + `, string(records[2].Result)) +} + +func TestUnknownViewedSSEServerSelectionIsRejectedBeforeWriting(t *testing.T) { + sendError := make(chan error, 1) + recorder := serveSSEService(t, "watch", &unknownViewService{sendError: sendError}) + requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") + records := jsonRPCSSERecords(t, recorder.Body.String()) + require.Len(t, records, 1) + require.Empty(t, records[0].Params) + require.NotEmpty(t, records[0].Error) +} + +func serveSSE(t *testing.T, method string) *httptest.ResponseRecorder { + t.Helper() + return serveSSEService(t, method, &viewedService{}) +} + +func serveSSEService(t *testing.T, method string, svc service.Service) *httptest.ResponseRecorder { + t.Helper() + server := New( + service.NewEndpoints(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + ) + body := []byte(` + "`" + `{"jsonrpc":"2.0","id":"1","method":"` + "`" + ` + method + ` + "`" + `"}` + "`" + `) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("POST", "/events", bytes.NewReader(body))) + require.Equal(t, http.StatusOK, recorder.Code) + return recorder +} + +type sseRecord struct { + Params json.RawMessage ` + "`" + `json:"params"` + "`" + ` + Result json.RawMessage ` + "`" + `json:"result"` + "`" + ` + Error json.RawMessage ` + "`" + `json:"error"` + "`" + ` +} + +func jsonRPCSSERecords(t *testing.T, event string) []sseRecord { + t.Helper() + var records []sseRecord + for _, line := range strings.Split(event, "\n") { + if strings.HasPrefix(line, "data:") { + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + var record sseRecord + require.NoError(t, json.Unmarshal([]byte(data), &record)) + records = append(records, record) + } + } + return records +} + +func viewedEvent() *service.Event { + return &service.Event{ + EventID: "event-1", + Profile: &service.Profile{DisplayName: "Ada"}, + } +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` diff --git a/codegen/go_transform.go b/codegen/go_transform.go index 911543ef3e..90dc1c57a6 100644 --- a/codegen/go_transform.go +++ b/codegen/go_transform.go @@ -1,15 +1,74 @@ +// This file generates Go transformations between compatible design types. +// Recursive helpers carry the package path for each side through nested named +// declarations so emitted references use the package selected during planning. package codegen import ( "bytes" "fmt" "reflect" + "slices" + "strconv" "strings" "text/template" "goa.design/goa/v3/expr" ) +type ( + // transformSnapshot copies one expression graph without merging user types + // that happen to come from the same authored declaration. + transformSnapshot struct { + attributes map[*expr.AttributeExpr]*expr.AttributeExpr + originals map[*expr.AttributeExpr]*expr.AttributeExpr + types map[expr.DataType]expr.DataType + } + + // transformSnapshotAttributor passes the original expression to name + // lookups that recorded expressions before the transform copied them. + transformSnapshotAttributor struct { + attributor Attributor + originals map[*expr.AttributeExpr]*expr.AttributeExpr + } + + // transformAttributePair identifies one exact pair in a plan. + transformAttributePair struct { + source *expr.AttributeExpr + target *expr.AttributeExpr + } + + // transformUnwrapChoice is the result of one planned UnwrapPair call. + transformUnwrapChoice struct { + source *expr.AttributeExpr + target *expr.AttributeExpr + directive *WrapDirective + } + + // transformStructuralChoices remembers what the two structural hooks + // returned while the transform was planned. + transformStructuralChoices struct { + unwrap func(*expr.AttributeExpr, *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) + fieldPair func(*expr.AttributeExpr, *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) + planUnionHelpers func(*expr.AttributeExpr, *expr.AttributeExpr, func(*expr.AttributeExpr, *expr.AttributeExpr)) + unchanged func() bool + mutationErr error + sourceSnapshot *transformSnapshot + targetSnapshot *transformSnapshot + unwrapPairs map[transformAttributePair]transformUnwrapChoice + fieldPairs map[transformAttributePair]transformAttributePair + planned bool + } + + // transformValueReference identifies one mutable value on the active copy + // path. Slice length and capacity distinguish separate views of one array. + transformValueReference struct { + typeOf reflect.Type + pointer uintptr + length int + capacity int + } +) + var transformGoArrayT, transformGoMapT, transformGoUnionT *template.Template // NOTE: can't initialize inline because https://github.com/golang/go/issues/1817 @@ -23,6 +82,423 @@ func init() { transformGoUnionT = template.Must(template.New("transformGoUnion").Funcs(fm).Parse(codegenTemplates.Read(transformGoUnionTmplName))) } +// newTransformSnapshot creates an exact, cycle-safe expression copier for one +// side of a transform. +func newTransformSnapshot() *transformSnapshot { + return &transformSnapshot{ + attributes: make(map[*expr.AttributeExpr]*expr.AttributeExpr), + originals: make(map[*expr.AttributeExpr]*expr.AttributeExpr), + types: make(map[expr.DataType]expr.DataType), + } +} + +// attribute copies attribute and every expression reachable from it. The +// placeholder is recorded before child types are copied so a true recursive +// edge points back to the same copied value. +func (s *transformSnapshot) attribute(attribute *expr.AttributeExpr) *expr.AttributeExpr { + if attribute == nil { + return nil + } + if _, copied := s.originals[attribute]; copied { + return attribute + } + if copied, ok := s.attributes[attribute]; ok { + return copied + } + copied := &expr.AttributeExpr{} + s.attributes[attribute] = copied + s.originals[copied] = attribute + copied.Type = s.dataType(attribute.Type) + copied.Bases = s.dataTypes(attribute.Bases) + copied.References = s.dataTypes(attribute.References) + copied.Description = attribute.Description + if attribute.Docs != nil { + docs := *attribute.Docs + copied.Docs = &docs + } + if attribute.Validation != nil { + copied.Validation = attribute.Validation.Dup() + copied.Validation.Values = copyTransformValue(attribute.Validation.Values).([]any) + } + copied.Meta = copyTransformMeta(attribute.Meta) + copied.DefaultValue = copyTransformValue(attribute.DefaultValue) + copied.DSLFunc = attribute.DSLFunc + if len(attribute.UserExamples) > 0 { + copied.UserExamples = make([]*expr.ExampleExpr, len(attribute.UserExamples)) + for index, example := range attribute.UserExamples { + if example == nil { + continue + } + value := *example + value.Value = copyTransformValue(example.Value) + copied.UserExamples[index] = &value + } + } + return copied +} + +// original returns the caller expression that was copied into the plan. +func (a *transformSnapshotAttributor) original(attribute *expr.AttributeExpr) *expr.AttributeExpr { + if original := a.originals[attribute]; original != nil { + return original + } + return attribute +} + +// Name gives the wrapped resolver the expression it used during name planning. +func (a *transformSnapshotAttributor) Name(attribute *expr.AttributeExpr, pkg string, pointer, useDefault bool) string { + return a.attributor.Name(a.original(attribute), pkg, pointer, useDefault) +} + +// Ref gives the wrapped resolver the expression it used during name planning. +func (a *transformSnapshotAttributor) Ref(attribute *expr.AttributeExpr, pkg string) string { + return a.attributor.Ref(a.original(attribute), pkg) +} + +// Field gives the wrapped resolver the field it used during name planning. +func (a *transformSnapshotAttributor) Field(attribute *expr.AttributeExpr, name string, firstUpper bool) string { + return a.attributor.Field(a.original(attribute), name, firstUpper) +} + +// Package gives the wrapped resolver the expression it used during planning. +func (a *transformSnapshotAttributor) Package(attribute *expr.AttributeExpr) string { + return a.attributor.Package(a.original(attribute)) +} + +// Enter retains the translation while entering the caller's planned child. +func (a *transformSnapshotAttributor) Enter(attribute *expr.AttributeExpr) Attributor { + return &transformSnapshotAttributor{ + attributor: a.attributor.Enter(a.original(attribute)), + originals: a.originals, + } +} + +// IsSumType reports the layout selected by the caller's attributor. +func (a *transformSnapshotAttributor) IsSumType() bool { + return a.attributor.IsSumType() +} + +// ValidatorCall gives the wrapped resolver the expression it used during +// validation name planning. +func (a *transformSnapshotAttributor) ValidatorCall(attribute *expr.AttributeExpr, view, target, path string) string { + return a.attributor.ValidatorCall(a.original(attribute), view, target, path) +} + +// Scope returns the name scope owned by the caller's resolver. +func (a *transformSnapshotAttributor) Scope() *NameScope { + return a.attributor.Scope() +} + +// OneofWrapper forwards the gRPC oneof lookup when the wrapped resolver owns +// that lookup. A different resolver cannot render a protobuf oneof. +func (a *transformSnapshotAttributor) OneofWrapper(attribute *expr.AttributeExpr) string { + resolver, ok := a.attributor.(interface { + OneofWrapper(*expr.AttributeExpr) string + }) + if !ok { + panic("transform name resolver cannot resolve a protobuf oneof wrapper") // bug + } + return resolver.OneofWrapper(a.original(attribute)) +} + +// dataTypes copies a list through the same graph so shared and recursive +// declarations remain shared in the snapshot. +func (s *transformSnapshot) dataTypes(dataTypes []expr.DataType) []expr.DataType { + if dataTypes == nil { + return nil + } + copied := make([]expr.DataType, len(dataTypes)) + for index, dataType := range dataTypes { + copied[index] = s.dataType(dataType) + } + return copied +} + +// dataType copies one type while preserving its exact graph identity. +func (s *transformSnapshot) dataType(dataType expr.DataType) expr.DataType { + if dataType == nil || dataType == expr.Empty { + return dataType + } + if copied, ok := s.types[dataType]; ok { + return copied + } + switch actual := dataType.(type) { + case expr.Primitive: + return actual + case *expr.Array: + copied := &expr.Array{NonNullableElems: actual.NonNullableElems} + s.types[dataType] = copied + copied.ElemType = s.attribute(actual.ElemType) + return copied + case *expr.Object: + copied := &expr.Object{} + s.types[dataType] = copied + for _, named := range *actual { + copied.Set(named.Name, s.attribute(named.Attribute)) + } + return copied + case *expr.Map: + copied := &expr.Map{} + s.types[dataType] = copied + copied.KeyType = s.attribute(actual.KeyType) + copied.ElemType = s.attribute(actual.ElemType) + return copied + case *expr.Union: + copied := &expr.Union{ + TypeName: actual.TypeName, + TypeKey: actual.TypeKey, + ValueKey: actual.ValueKey, + Values: make([]*expr.NamedAttributeExpr, len(actual.Values)), + } + s.types[dataType] = copied + for index, named := range actual.Values { + copied.Values[index] = &expr.NamedAttributeExpr{ + Name: named.Name, + Attribute: s.attribute(named.Attribute), + } + } + return copied + case expr.UserType: + copied := actual.Dup(nil) + s.types[dataType] = copied + copied.SetAttribute(s.attribute(actual.Attribute())) + return copied + default: + panic(fmt.Sprintf("cannot snapshot transform type %T", dataType)) // bug + } +} + +// copyTransformMeta copies both the metadata map and its value slices. +func copyTransformMeta(meta expr.MetaExpr) expr.MetaExpr { + if meta == nil { + return nil + } + copied := meta.Dup() + for name, values := range copied { + copied[name] = slices.Clone(values) + } + return copied +} + +// copyTransformValue copies the values accepted by Goa defaults, validations, +// and examples without changing their concrete Go type. +func copyTransformValue(value any) any { + if value == nil { + return nil + } + return copyTransformReflectValue( + reflect.ValueOf(value), + make(map[transformValueReference]struct{}), + ).Interface() +} + +// copyTransformReflectValue copies mutable JSON-compatible containers. +// Values with unsupported mutable kinds are rejected instead of shared. +func copyTransformReflectValue(value reflect.Value, active map[transformValueReference]struct{}) reflect.Value { + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copied := reflect.New(value.Type()).Elem() + copied.Set(copyTransformReflectValue(value.Elem(), active)) + return copied + case reflect.Pointer: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + reference := enterTransformValueReference(value, active) + defer delete(active, reference) + copied := reflect.New(value.Type().Elem()) + copied.Elem().Set(copyTransformReflectValue(value.Elem(), active)) + return copied + case reflect.Slice: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + reference := enterTransformValueReference(value, active) + defer delete(active, reference) + copied := reflect.MakeSlice(value.Type(), value.Len(), value.Len()) + for index := range value.Len() { + copied.Index(index).Set(copyTransformReflectValue(value.Index(index), active)) + } + return copied + case reflect.Map: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + reference := enterTransformValueReference(value, active) + defer delete(active, reference) + copied := reflect.MakeMapWithSize(value.Type(), value.Len()) + iterator := value.MapRange() + for iterator.Next() { + copied.SetMapIndex( + copyTransformReflectValue(iterator.Key(), active), + copyTransformReflectValue(iterator.Value(), active), + ) + } + return copied + case reflect.Array: + copied := reflect.New(value.Type()).Elem() + for index := range value.Len() { + copied.Index(index).Set(copyTransformReflectValue(value.Index(index), active)) + } + return copied + case reflect.Struct: + copied := reflect.New(value.Type()).Elem() + copied.Set(value) + for index := range value.NumField() { + field := value.Type().Field(index) + if !field.IsExported() { + if transformTypeContainsReference(field.Type) { + panic(fmt.Sprintf( + "cannot copy transform value of type %s: unexported field %s contains mutable data", + value.Type(), + field.Name, + )) + } + continue + } + copied.Field(index).Set(copyTransformReflectValue(value.Field(index), active)) + } + return copied + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, + reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, + reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, reflect.String: + return value + default: + panic(fmt.Sprintf("cannot copy transform value of type %s", value.Type())) + } +} + +// enterTransformValueReference rejects a value that points back to one already +// being copied. Repeated values outside the active path are copied separately. +func enterTransformValueReference(value reflect.Value, active map[transformValueReference]struct{}) transformValueReference { + reference := transformValueReference{ + typeOf: value.Type(), + pointer: value.Pointer(), + } + if value.Kind() == reflect.Slice { + reference.length = value.Len() + reference.capacity = value.Cap() + } + if _, exists := active[reference]; exists { + panic(fmt.Sprintf("cannot copy cyclic transform value of type %s", value.Type())) + } + active[reference] = struct{}{} + return reference +} + +// transformTypeContainsReference reports whether a private field could share +// mutable state with the expression supplied by the caller. +func transformTypeContainsReference(valueType reflect.Type) bool { + switch valueType.Kind() { + case reflect.Slice, reflect.Map, reflect.Pointer, reflect.Interface, + reflect.Func, reflect.Chan, reflect.UnsafePointer: + return true + case reflect.Array: + return transformTypeContainsReference(valueType.Elem()) + case reflect.Struct: + for index := range valueType.NumField() { + if transformTypeContainsReference(valueType.Field(index).Type) { + return true + } + } + } + return false +} + +// captureTransformStructuralHooks copies the hook set and replaces planning +// callbacks with memoized versions. Planning may add a choice; rendering may +// only read one that planning already made. unchanged checks that a hook left +// the plan-owned expression graphs intact. +func captureTransformStructuralHooks(hooks *TransformHooks, sourceSnapshot, targetSnapshot *transformSnapshot, unchanged func() bool) (*TransformHooks, *transformStructuralChoices) { + if hooks == nil { + return nil, nil + } + copied := *hooks + choices := &transformStructuralChoices{ + unwrap: copied.UnwrapPair, + fieldPair: copied.FieldPairAttrs, + planUnionHelpers: copied.PlanUnionHelpers, + unchanged: unchanged, + sourceSnapshot: sourceSnapshot, + targetSnapshot: targetSnapshot, + unwrapPairs: make(map[transformAttributePair]transformUnwrapChoice), + fieldPairs: make(map[transformAttributePair]transformAttributePair), + } + if choices.unwrap != nil { + copied.UnwrapPair = choices.unwrapPair + } + if choices.fieldPair != nil { + copied.FieldPairAttrs = choices.fieldPairAttrs + } + if choices.planUnionHelpers != nil { + copied.PlanUnionHelpers = choices.planUnionHelpersForPlan + } + return &copied, choices +} + +// unwrapPair returns the first attributes and wrapper instruction chosen for +// pair. After planning, a missing choice means rendering took a new path. +func (c *transformStructuralChoices) unwrapPair(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + pair := transformAttributePair{source: source, target: target} + if choice, ok := c.unwrapPairs[pair]; ok { + return choice.source, choice.target, choice.directive + } + if c.planned { + panic("transform render requested an unplanned unwrap choice") // bug + } + source, target, directive := c.unwrap(source, target) + c.recordMutation("UnwrapPair") + source = c.sourceSnapshot.attribute(source) + target = c.targetSnapshot.attribute(target) + if directive != nil { + copy := *directive + copy.Target = c.targetSnapshot.attribute(directive.Target) + directive = © + } + c.unwrapPairs[pair] = transformUnwrapChoice{ + source: source, + target: target, + directive: directive, + } + return source, target, directive +} + +// fieldPairAttrs returns the first attributes chosen for pair. +func (c *transformStructuralChoices) fieldPairAttrs(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + pair := transformAttributePair{source: source, target: target} + if choice, ok := c.fieldPairs[pair]; ok { + return choice.source, choice.target + } + if c.planned { + panic("transform render requested an unplanned field-pair choice") // bug + } + source, target = c.fieldPair(source, target) + c.recordMutation("FieldPairAttrs") + source = c.sourceSnapshot.attribute(source) + target = c.targetSnapshot.attribute(target) + c.fieldPairs[pair] = transformAttributePair{source: source, target: target} + return source, target +} + +// planUnionHelpersForPlan records the union helper choices and rejects a hook +// that changed a source or target expression while deciding those choices. +func (c *transformStructuralChoices) planUnionHelpersForPlan(source, target *expr.AttributeExpr, record func(*expr.AttributeExpr, *expr.AttributeExpr)) { + c.planUnionHelpers(source, target, record) + c.recordMutation("PlanUnionHelpers") +} + +// recordMutation preserves the first planning-hook violation so +// NewTransformPlan can return it instead of a downstream compatibility error. +func (c *transformStructuralChoices) recordMutation(name string) { + if c.mutationErr == nil && !c.unchanged() { + c.mutationErr = fmt.Errorf("transform planning hook %s changed the retained plan", name) + } +} + // GoTransform produces Go code that initializes the data structure defined // by target from an instance of the data structure described by source. // The data structures can be objects, arrays or maps. The algorithm @@ -63,20 +539,363 @@ func GoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string } // GoTransformWithAttrs is GoTransform with a caller built TransformAttrs. It -// lets generators customize the transformation via TransformAttrs.Hooks, see +// plans the conversion before writing it so structural hooks run once. Returned +// helpers keep their released Name and a nil Declaration for existing plugins. +// Generators may customize the conversion through TransformAttrs.Hooks; see // TransformHooks. func GoTransformWithAttrs(source, target *expr.AttributeExpr, sourceVar, targetVar string, ta *TransformAttrs, newVar bool) (string, []*TransformFunctionData, error) { - code, err := TransformAttribute(source, target, sourceVar, targetVar, newVar, ta) + plan, err := NewTransformPlan(source, target, ta.Prefix, ta.Hooks) if err != nil { return "", nil, err } + if err := plan.BindContexts(ta.SourceCtx, ta.TargetCtx); err != nil { + return "", nil, err + } - funcs, err := collectHelpers(source, target, true, true, ta, make(map[string]*TransformFunctionData)) + // Existing callers choose exact helper names while writing a file. Use one + // declaration for every occurrence of the same released name so Render can + // verify that the shared function body is identical. + legacyPackage := newGeneratedPackage("legacy transform helpers", "goa.design/goa/v3/codegen/transform", "") + declarations := make(map[string]*NameDeclaration, len(plan.helpers)) + nameAttrs := &TransformAttrs{ + SourceCtx: plan.sourceCtx, + TargetCtx: plan.targetCtx, + Prefix: plan.prefix, + } + for _, helper := range plan.helpers { + name := legacyTransformHelperName(helper.Source, helper.Target, nameAttrs) + declaration := declarations[name] + if declaration == nil { + declaration = NewExactName(NameFunction, name) + if err := legacyPackage.DeclareName(declaration); err != nil { + return "", nil, err + } + declarations[name] = declaration + } + if err := plan.BindHelperDeclaration(helper.ID, declaration); err != nil { + return "", nil, err + } + } + if err := legacyPackage.freeze(); err != nil { + return "", nil, err + } + code, helpers, err := plan.Render(sourceVar, targetVar, newVar) if err != nil { return "", nil, err } + for _, helper := range helpers { + helper.ID = TransformHelperID{} + helper.Declaration = nil + } + return code, helpers, nil +} - return strings.TrimRight(code, "\n"), funcs, nil +// NewTransformPlan copies the source and target expression graphs and records +// every recursive conversion needed to turn one into the other. Distinct user +// types remain distinct even when they were copied from one declaration, and a +// true recursive edge points back to the same copied type. The plan walks only +// these copies during Render. It retains the original expression identities so +// name resolvers can find names they recorded before this call. +// +// NewTransformPlan calls UnwrapPair, FieldPairAttrs, and PlanUnionHelpers while +// planning and keeps their returned choices. The rendering hooks are called +// during Render. The caller may reuse or change source, target, and the +// TransformHooks value after this function returns; the plan owns its +// expression copies and its copied hook fields. A planning hook must inspect +// those copies without changing them; this function returns an error if it +// detects a mutation. +func NewTransformPlan(source, target *expr.AttributeExpr, prefix string, hooks *TransformHooks) (*TransformPlan, error) { + sourceSnapshot := newTransformSnapshot() + targetSnapshot := newTransformSnapshot() + source = sourceSnapshot.attribute(source) + target = targetSnapshot.attribute(target) + baselineSource := newTransformSnapshot() + baselineTarget := newTransformSnapshot() + plan := &TransformPlan{ + source: source, + target: target, + sourceBaseline: baselineSource.attribute(source), + targetBaseline: baselineTarget.attribute(target), + sourceOriginals: sourceSnapshot.originals, + targetOriginals: targetSnapshot.originals, + prefix: prefix, + operations: []*transformOperation{{}}, + renders: make(map[transformRenderRequest]transformRenderResult), + } + retainedHooks, structuralChoices := captureTransformStructuralHooks(hooks, sourceSnapshot, targetSnapshot, func() bool { + return !plan.changed() + }) + plan.hooks = retainedHooks + err := planTransformOperation(source, target, true, true, plan.operations[0], make(map[transformPair]TransformHelperID), plan) + if structuralChoices != nil && structuralChoices.mutationErr != nil { + return nil, structuralChoices.mutationErr + } + if err != nil { + return nil, err + } + if structuralChoices != nil { + structuralChoices.planned = true + } + return plan, nil +} + +// Helpers returns the recursive conversion functions selected by the plan so a +// generator can declare their names before writing code. Changing the returned +// slice or its Source and Target attributes does not change the plan. Source +// and Target identify the caller attributes that the plan copied; Render keeps +// and uses separate private attributes. +func (p *TransformPlan) Helpers() []TransformHelper { + helpers := slices.Clone(p.helpers) + for index := range helpers { + sourceSnapshot := newTransformSnapshot() + targetSnapshot := newTransformSnapshot() + helpers[index].Source = sourceSnapshot.attribute(helpers[index].Source) + helpers[index].Target = targetSnapshot.attribute(helpers[index].Target) + } + return helpers +} + +// BindHelperDeclaration assigns the package-level function declared for one +// value returned by Helpers. Equivalent conversions may share a declaration; +// Render verifies that their generated definitions match. +func (p *TransformPlan) BindHelperDeclaration(id TransformHelperID, declaration *NameDeclaration) error { + if id.plan != p || id.index < 0 || id.index >= len(p.helpers) { + return fmt.Errorf("transform helper does not belong to this plan") + } + if declaration == nil { + return fmt.Errorf("transform helper declaration must not be nil") + } + if declaration.Kind() != NameFunction { + return fmt.Errorf("transform helper declaration must be a function, got %s", declaration.Kind()) + } + helper := &p.helpers[id.index] + if helper.Declaration != nil && helper.Declaration != declaration { + return fmt.Errorf("transform helper already has a different declaration") + } + helper.Declaration = declaration + return nil +} + +// BindContexts copies the source and target type resolvers used by every call +// and helper definition. Call it after helper declarations and package names +// are final. It may be called once. +func (p *TransformPlan) BindContexts(source, target *AttributeContext) error { + if source == nil || target == nil { + return fmt.Errorf("transform contexts must not be nil") + } + if p.sourceCtx != nil || p.targetCtx != nil { + return fmt.Errorf("transform contexts are already bound") + } + p.sourceCtx = source.Dup() + p.sourceCtx.Scope = &transformSnapshotAttributor{ + attributor: source.Scope, + originals: p.sourceOriginals, + } + p.targetCtx = target.Dup() + p.targetCtx.Scope = &transformSnapshotAttributor{ + attributor: target.Scope, + originals: p.targetOriginals, + } + return nil +} + +// Render writes the top-level conversion and its recursive function bodies. +// Every helper must have a declaration and BindContexts must have been called. +// Repeating the same source variable, target variable, and new-variable choice +// returns the exact first result without calling hooks again. Different +// variables are separate generation requests and can produce different code. +func (p *TransformPlan) Render(sourceVar, targetVar string, newVar bool) (code string, helpers []*TransformFunctionData, err error) { + if p.sourceCtx == nil || p.targetCtx == nil { + return "", nil, fmt.Errorf("transform contexts are not bound") + } + request := transformRenderRequest{sourceVar: sourceVar, targetVar: targetVar, newVar: newVar} + if cached, ok := p.renders[request]; ok { + return cached.code, copyTransformFunctionData(cached.helpers), cached.err + } + if p.changed() { + return "", nil, fmt.Errorf("transform render hook changed the retained plan") + } + defer func() { + if p.changed() { + code = "" + helpers = nil + err = fmt.Errorf("transform render hook changed the retained plan") + } + p.renders[request] = transformRenderResult{ + code: code, + helpers: copyTransformFunctionData(helpers), + err: err, + } + helpers = copyTransformFunctionData(helpers) + }() + var hooks *TransformHooks + if p.hooks != nil { + copied := *p.hooks + hooks = &copied + } + renderAttrs := TransformAttrs{ + SourceCtx: p.sourceCtx.Dup(), + TargetCtx: p.targetCtx.Dup(), + Prefix: p.prefix, + Hooks: hooks, + } + renderAttrs.helpers = make(map[TransformHelperID]TransformHelper, len(p.helpers)) + for _, planned := range p.helpers { + if planned.Declaration == nil { + return "", nil, fmt.Errorf("transform helper occurrence %d has no declaration", planned.Occurrence) + } + renderAttrs.helpers[planned.ID] = planned + } + renderAttrs.calls = &transformCallCursor{calls: p.operations[0].calls} + code, err = TransformAttribute(p.source, p.target, sourceVar, targetVar, newVar, &renderAttrs) + if err != nil { + return "", nil, err + } + if err := renderAttrs.calls.complete("top-level transform"); err != nil { + return "", nil, err + } + helpers = make([]*TransformFunctionData, 0, len(p.helpers)) + definitions := make(map[*NameDeclaration]*TransformFunctionData, len(p.helpers)) + for index, planned := range p.helpers { + entered := enterTransformAttrs(planned.Source, planned.Target, &renderAttrs) + entered.calls = &transformCallCursor{calls: p.operations[index+1].calls} + helper, err := generateTransformHelper(planned, entered) + if err != nil { + return "", nil, err + } + if err := entered.calls.complete(fmt.Sprintf("transform helper occurrence %d", planned.Occurrence)); err != nil { + return "", nil, err + } + if previous := definitions[planned.Declaration]; previous != nil { + if !transformFunctionDefinitionsEqual(previous, helper) { + return "", nil, fmt.Errorf("transform helper declaration %q has different definitions", planned.Declaration.Name()) + } + continue + } + definitions[planned.Declaration] = helper + helpers = append(helpers, helper) + } + return strings.TrimRight(code, "\n"), helpers, nil +} + +// copyTransformFunctionData copies generated helper descriptions before they +// cross the plan boundary. Declaration is intentionally shared: it is the +// immutable package-level name selected before rendering. +func copyTransformFunctionData(helpers []*TransformFunctionData) []*TransformFunctionData { + if helpers == nil { + return nil + } + copied := make([]*TransformFunctionData, len(helpers)) + for index, helper := range helpers { + if helper == nil { + continue + } + value := *helper + copied[index] = &value + } + return copied +} + +// changed reports whether code generation would read an expression different +// from the one retained when planning completed. Render hooks receive these +// expressions for inspection only; a mutation invalidates the render attempt. +func (p *TransformPlan) changed() bool { + return !transformAttributesEqual(p.source, p.sourceBaseline, make(map[transformAttributePair]struct{})) || + !transformAttributesEqual(p.target, p.targetBaseline, make(map[transformAttributePair]struct{})) +} + +// transformAttributesEqual compares the expression facts that conversion +// planning and rendering consume. It follows paired recursive attributes once. +func transformAttributesEqual(left, right *expr.AttributeExpr, seen map[transformAttributePair]struct{}) bool { + if left == nil || right == nil { + return left == right + } + pair := transformAttributePair{source: left, target: right} + if _, compared := seen[pair]; compared { + return true + } + seen[pair] = struct{}{} + if left.Description != right.Description || !reflect.DeepEqual(left.Docs, right.Docs) || + !reflect.DeepEqual(left.Validation, right.Validation) || !reflect.DeepEqual(left.Meta, right.Meta) || + !reflect.DeepEqual(left.DefaultValue, right.DefaultValue) || len(left.Bases) != len(right.Bases) || + len(left.References) != len(right.References) || len(left.UserExamples) != len(right.UserExamples) { + return false + } + for index := range left.Bases { + if !transformDataTypesEqual(left.Bases[index], right.Bases[index], seen) { + return false + } + } + for index := range left.References { + if !transformDataTypesEqual(left.References[index], right.References[index], seen) { + return false + } + } + for index := range left.UserExamples { + if left.UserExamples[index] == nil || right.UserExamples[index] == nil { + if left.UserExamples[index] != right.UserExamples[index] { + return false + } + continue + } + if left.UserExamples[index].Summary != right.UserExamples[index].Summary || + left.UserExamples[index].Description != right.UserExamples[index].Description || + !reflect.DeepEqual(left.UserExamples[index].Value, right.UserExamples[index].Value) { + return false + } + } + return transformDataTypesEqual(left.Type, right.Type, seen) +} + +// transformDataTypesEqual compares the expression type graph below two +// attributes without comparing DSL functions or expression pointer identities. +func transformDataTypesEqual(left, right expr.DataType, seen map[transformAttributePair]struct{}) bool { + if left == nil || right == nil { + return left == right + } + if reflect.TypeOf(left) != reflect.TypeOf(right) { + return false + } + switch left := left.(type) { + case expr.Primitive: + return left == right.(expr.Primitive) + case *expr.Array: + right := right.(*expr.Array) + return left.NonNullableElems == right.NonNullableElems && transformAttributesEqual(left.ElemType, right.ElemType, seen) + case *expr.Object: + right := right.(*expr.Object) + if len(*left) != len(*right) { + return false + } + for index, attribute := range *left { + other := (*right)[index] + if attribute.Name != other.Name || !transformAttributesEqual(attribute.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Map: + right := right.(*expr.Map) + return transformAttributesEqual(left.KeyType, right.KeyType, seen) && + transformAttributesEqual(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.(*expr.Union) + if left.TypeName != right.TypeName || left.TypeKey != right.TypeKey || left.ValueKey != right.ValueKey || len(left.Values) != len(right.Values) { + return false + } + for index, attribute := range left.Values { + other := right.Values[index] + if attribute.Name != other.Name || !transformAttributesEqual(attribute.Attribute, other.Attribute, seen) { + return false + } + } + return true + case expr.UserType: + right := right.(expr.UserType) + return left.Name() == right.Name() && transformAttributesEqual(left.Attribute(), right.Attribute(), seen) + default: + panic(fmt.Sprintf("cannot compare transform type %T", left)) // bug + } } // TransformAttribute returns the code to transform source attribute to target @@ -88,8 +907,9 @@ func TransformAttribute(source, target *expr.AttributeExpr, sourceVar, targetVar if h := ta.Hooks; h != nil && h.UnwrapPair != nil { var dir *WrapDirective source, target, dir = h.UnwrapPair(source, target) - prelude = dir.apply(&sourceVar, &targetVar, &newVar) + prelude = dir.apply(&sourceVar, &targetVar, &newVar, ta) } + ta = enterTransformAttrs(source, target, ta) if err := IsCompatible(source.Type, target.Type, sourceVar, targetVar); err != nil { return "", err } @@ -127,20 +947,31 @@ func TransformAttribute(source, target *expr.AttributeExpr, sourceVar, targetVar return prelude + code, nil } -// TransformHelperName returns the transformation function name to initialize a -// target user type from an instance of a source user type. It is exported so -// that TransformHooks implementations can compute the names of the helper -// functions the engine collects. +// TransformHelperName returns the recursive function used to initialize target +// from source. A TransformPlan calls it only for named object pairs. +// GoTransformWithAttrs still chooses its helper name while writing code. func TransformHelperName(source, target *expr.AttributeExpr, ta *TransformAttrs) string { + if ta.calls != nil { + call := ta.calls.consume() + helper, ok := ta.helpers[call.helper] + if !ok { + panic("planned transform call references an unknown helper") // bug + } + return helper.Declaration.Name() + } + return legacyTransformHelperName(source, target, ta) +} + +// legacyTransformHelperName preserves the naming strategy used by generators +// that have not yet bound their package-level helper declarations. +func legacyTransformHelperName(source, target *expr.AttributeExpr, ta *TransformAttrs) string { var ( sname string tname string prefix string ) { - if h := ta.Hooks; h != nil && h.HelperNameAttrs != nil { - source, target = h.HelperNameAttrs(source, target) - } + ta = enterTransformAttrs(source, target, ta) sname = Goify(ta.SourceCtx.Scope.Name(source, ta.SourceCtx.Pkg(source), ta.SourceCtx.Pointer, ta.SourceCtx.UseDefault), true) tname = Goify(ta.TargetCtx.Scope.Name(target, ta.TargetCtx.Pkg(target), ta.TargetCtx.Pointer, ta.TargetCtx.UseDefault), true) prefix = ta.Prefix @@ -151,6 +982,23 @@ func TransformHelperName(source, target *expr.AttributeExpr, ta *TransformAttrs) return Goify(prefix+sname+"To"+tname, false) } +// usesTransformHelper reports whether source and target can define one named +// helper function signature. Anonymous objects are rendered inline because +// they have no package-level parameter or result declaration. +func usesTransformHelper(source, target *expr.AttributeExpr) bool { + _, sourceNamed := source.Type.(expr.UserType) + _, targetNamed := target.Type.(expr.UserType) + return sourceNamed && targetNamed && expr.IsObject(source.Type) && expr.IsObject(target.Type) +} + +// transformFunctionDefinitionsEqual reports whether one function can serve +// every call assigned to the same declaration. +func transformFunctionDefinitionsEqual(left, right *TransformFunctionData) bool { + return left.ParamTypeRef == right.ParamTypeRef && + left.ResultTypeRef == right.ResultTypeRef && + left.Code == right.Code +} + // transformPrimitive returns the code to transform source primitive type to // target primitive type. The caller (TransformAttribute) already verified that // source and target are compatible. @@ -276,13 +1124,15 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st // iterate through attributes to initialize rest of the struct fields and // handle default values walkMatches(source, target, func(srcMatt, tgtMatt *expr.MappedAttributeExpr, srcc, tgtc *expr.AttributeExpr, n string) { + srcField := ta.SourceCtx.Scope.Field(srcc, srcMatt.ElemName(n), true) + tgtField := ta.TargetCtx.Scope.Field(tgtc, tgtMatt.ElemName(n), true) h := ta.Hooks if h != nil && h.FieldPairAttrs != nil { srcc, tgtc = h.FieldPairAttrs(srcc, tgtc) } var ( - srcVar = sourceVar + "." + ta.SourceCtx.Scope.Field(srcc, srcMatt.ElemName(n), true) - tgtVar = targetVar + "." + ta.TargetCtx.Scope.Field(tgtc, tgtMatt.ElemName(n), true) + srcVar = sourceVar + "." + srcField + tgtVar = targetVar + "." + tgtField ) var dir *WrapDirective if h != nil && h.UnwrapPair != nil { @@ -291,6 +1141,7 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st if err = IsCompatible(srcc.Type, tgtc.Type, sourceVar, targetVar); err != nil { return } + fieldAttrs := enterTransformAttrs(srcc, tgtc, ta) var code string { @@ -298,39 +1149,36 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st // transforming the field value: nil guards and default value // handling keep using the unwrapped field variables. dispatchSrcVar, dispatchTgtVar, dispatchNewVar := srcVar, tgtVar, false - prelude := dir.apply(&dispatchSrcVar, &dispatchTgtVar, &dispatchNewVar) + prelude := dir.apply(&dispatchSrcVar, &dispatchTgtVar, &dispatchNewVar, ta) var postlude string if expr.IsUnion(tgtc.Type) && ta.TargetCtx.IsFieldPointer(n, tgtMatt.AttributeExpr) { unionVar := Goify(tgtMatt.ElemName(n), false) + "Value" - unionRef := ta.TargetCtx.Scope.Name(tgtc, ta.TargetCtx.Pkg(tgtc), false, ta.TargetCtx.UseDefault) + unionRef := fieldAttrs.TargetCtx.Scope.Name(tgtc, fieldAttrs.TargetCtx.Pkg(tgtc), false, fieldAttrs.TargetCtx.UseDefault) prelude += fmt.Sprintf("var %s %s\n", unionVar, unionRef) dispatchTgtVar = unionVar postlude = fmt.Sprintf("%s = &%s\n", tgtVar, unionVar) } - _, ok := srcc.Type.(expr.UserType) switch { case expr.IsArray(srcc.Type): if h != nil && h.TransformArray != nil { - code, err = h.TransformArray(expr.AsArray(srcc.Type), expr.AsArray(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = h.TransformArray(expr.AsArray(srcc.Type), expr.AsArray(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } else { - code, err = transformArray(expr.AsArray(srcc.Type), expr.AsArray(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = transformArray(expr.AsArray(srcc.Type), expr.AsArray(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } case expr.IsMap(srcc.Type): if h != nil && h.TransformMap != nil { - code, err = h.TransformMap(expr.AsMap(srcc.Type), expr.AsMap(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = h.TransformMap(expr.AsMap(srcc.Type), expr.AsMap(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } else { - code, err = transformMap(expr.AsMap(srcc.Type), expr.AsMap(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = transformMap(expr.AsMap(srcc.Type), expr.AsMap(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } case expr.IsUnion(srcc.Type): if h != nil && h.TransformUnion != nil { - code, err = h.TransformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, source, target, ta) + code, err = h.TransformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, source, target, fieldAttrs) } else { - code, err = transformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) - } - case ok: - if !expr.IsPrimitive(srcc.Type) { - code = fmt.Sprintf("%s = %s(%s)\n", dispatchTgtVar, TransformHelperName(srcc, tgtc, ta), dispatchSrcVar) + code, err = transformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } + case usesTransformHelper(srcc, tgtc): + code = fmt.Sprintf("%s = %s(%s)\n", dispatchTgtVar, TransformHelperName(srcc, tgtc, ta), dispatchSrcVar) case expr.IsObject(srcc.Type): code, err = TransformAttribute(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) } @@ -425,20 +1273,16 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st // source attribute is a primitive with default value // (the field is not a pointer in this case) code += "{\n\t" - var ( - zeroName string - nilable bool - ) + var zeroName string + nilable := IsNilable(tgtc.Type) || valueIsNilable(tdef) if h != nil && h.ZeroTypeName != nil { - var ok bool - if zeroName, ok = h.ZeroTypeName(tgtc); ok { - nilable = typeStringIsNilable(zeroName) + if name, ok := h.ZeroTypeName(tgtc); ok { + zeroName = name } } if zeroName == "" { if typeName, _ := GetMetaType(tgtc); typeName != "" { zeroName = typeName - nilable = typeStringIsNilable(typeName) } else if _, ok := tgtc.Type.(expr.UserType); ok { // aliased primitive zeroName = ta.TargetCtx.Scope.Ref(tgtc, ta.TargetCtx.Pkg(tgtc)) @@ -465,10 +1309,13 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st return buffer.String(), nil } -// typeStringIsNilable takes a go type as a string and checks for a '[]' or -// 'map[' prefix to see if it's a nilable primitive type. -func typeStringIsNilable(typeName string) bool { - return strings.HasPrefix(typeName, "[]") || strings.HasPrefix(typeName, "map[") +// valueIsNilable reports whether a typed default can be compared only with +// nil. It preserves named slices, maps, pointers, functions, and channels +// without inspecting the Go name supplied by field metadata. +func valueIsNilable(value any) bool { + kind := reflect.TypeOf(value).Kind() + return kind == reflect.Chan || kind == reflect.Func || kind == reflect.Interface || + kind == reflect.Map || kind == reflect.Pointer || kind == reflect.Slice } // transformArray generates Go code to transform source array to target array. @@ -476,16 +1323,24 @@ func transformArray(source, target *expr.Array, sourceVar, targetVar string, new if err := IsCompatible(source.ElemType.Type, target.ElemType.Type, sourceVar+"[0]", targetVar+"[0]"); err != nil { return "", err } + sourceElement := "val" + if ta.SourceCtx.IsArrayElementPointer(source) { + sourceElement = "*val" + } + loopVar, childAttrs := ta.EnterCollection() data := map[string]any{ - "ElemTypeRef": ta.TargetCtx.Scope.Ref(target.ElemType, ta.TargetCtx.Pkg(target.ElemType)), - "SourceElem": source.ElemType, - "TargetElem": target.ElemType, - "SourceVar": sourceVar, - "TargetVar": targetVar, - "NewVar": newVar, - "TransformAttrs": ta, - "LoopVar": string(rune(105 + strings.Count(targetVar, "["))), - "IsStruct": expr.IsObject(target.ElemType.Type), + "ElemTypeRef": ta.TargetCtx.Scope.Ref(target.ElemType, ta.TargetCtx.Pkg(target.ElemType)), + "SourceElem": source.ElemType, + "SourceElement": sourceElement, + "TargetElem": target.ElemType, + "SourceVar": sourceVar, + "TargetVar": targetVar, + "NewVar": newVar, + "TransformAttrs": childAttrs, + "LoopVar": loopVar, + "SourceIsObject": expr.IsObject(source.ElemType.Type), + "TargetElemPointer": ta.TargetCtx.IsArrayElementPointer(target), + "UseHelper": usesTransformHelper(source.ElemType, target.ElemType), } var buf bytes.Buffer if err := transformGoArrayT.Execute(&buf, data); err != nil { @@ -514,8 +1369,9 @@ func transformMap(source, target *expr.Map, sourceVar, targetVar string, newVar "NewVar": newVar, "TransformAttrs": ta, "LoopVar": "", - "IsKeyStruct": expr.IsObject(target.KeyType.Type), - "IsElemStruct": expr.IsObject(target.ElemType.Type), + "ElemIsObject": expr.IsObject(source.ElemType.Type), + "UseKeyHelper": usesTransformHelper(source.KeyType, target.KeyType), + "UseElemHelper": usesTransformHelper(source.ElemType, target.ElemType), } if depth := MapDepth(target); depth > 0 { data["LoopVar"] = string(rune(97 + depth)) @@ -553,29 +1409,26 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str unionPkg := ta.TargetCtx.Pkg(target) typeRef := ta.TargetCtx.Scope.Ref(target, unionPkg) - // Use deterministic temp var: 'obj' at top-level, 'tmp' for nested - // assignments. A "obj." prefix means this transform is emitted inside a - // case body of an enclosing union transform which already declared 'obj'. + // The outer union keeps Goa's released local spelling. Nested unions use + // numbered locals selected from traversal depth, never from caller code. tempVarName := "obj" - if strings.HasPrefix(targetVar, "obj.") { + if ta.unionDepth > 0 { tempVarName = "tmp" + tempVarName += strconv.Itoa(ta.unionDepth + 1) } + childAttrs := *ta + childAttrs.unionDepth++ cases := make([]map[string]any, 0, len(srcUnion.Values)) for i, st := range srcUnion.Values { tt := tgtUnion.Values[i] - castPkg := ta.TargetCtx.Pkg(tt.Attribute) - // When generating transforms outside of the type's package, some nested - // helper user types may not carry struct:pkg:path metadata. In that case - // default to the union type package rather than the current file package. - if castPkg == ta.TargetCtx.DefaultPkg && unionPkg != "" && unionPkg != ta.TargetCtx.DefaultPkg { - castPkg = unionPkg - } - useHelper := false - if _, ok := st.Attribute.Type.(expr.UserType); ok && expr.IsObject(st.Attribute.Type) { - if _, ok := tt.Attribute.Type.(expr.UserType); ok && expr.IsObject(tt.Attribute.Type) { - useHelper = true - } + branchAttrs := *ta + branchAttrs.SourceCtx = ta.SourceCtx.Enter(st.Attribute) + branchAttrs.TargetCtx = ta.TargetCtx.Enter(tt.Attribute) + useHelper := usesTransformHelper(st.Attribute, tt.Attribute) + helperName := "" + if useHelper { + helperName = TransformHelperName(st.Attribute, tt.Attribute, &branchAttrs) } cases = append(cases, map[string]any{ "CaseName": st.Name, @@ -583,22 +1436,22 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str "TargetFieldName": Goify(tt.Name, true), "SourceAttr": st.Attribute, "TargetAttr": tt.Attribute, - "TargetCastType": ta.TargetCtx.Scope.Ref(tt.Attribute, castPkg), + "TargetCastType": branchAttrs.TargetCtx.Scope.Ref(tt.Attribute, branchAttrs.TargetCtx.Pkg(tt.Attribute)), + "SourceNilable": IsNilable(st.Attribute.Type), "UseHelper": useHelper, - "HelperName": TransformHelperName(st.Attribute, tt.Attribute, ta), + "HelperName": helperName, }) } data := map[string]any{ - "SourceVar": sourceVar, - "TargetVar": targetVar, - "NewVar": newVar, - "TypeRef": typeRef, - "TargetIsPointer": strings.HasPrefix(typeRef, "*"), - "ValueTypeRef": strings.TrimPrefix(typeRef, "*"), - "TempVarName": tempVarName, - "Cases": cases, - "TransformAttrs": ta, + "SourceVar": sourceVar, + "TargetVar": targetVar, + "NewVar": newVar, + "TypeRef": typeRef, + "ValueTypeRef": ta.TargetCtx.Scope.Name(target, unionPkg, false, ta.TargetCtx.UseDefault), + "TempVarName": tempVarName, + "Cases": cases, + "TransformAttrs": &childAttrs, } var buf bytes.Buffer @@ -608,107 +1461,191 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str return buf.String(), nil } -// collectHelpers recurses through the given attributes and returns the -// transform helper functions required by the code GoTransform produces. The -// top-level call (topLevel true) does not generate a helper for the top-most -// user type because the generated code inlines that transformation; children -// of composite top-level types always get helpers. -// -// seen keeps track of generated transform functions to avoid infinite -// recursion on recursive types. -func collectHelpers(source, target *expr.AttributeExpr, req, topLevel bool, ta *TransformAttrs, seen map[string]*TransformFunctionData) (helpers []*TransformFunctionData, err error) { - if h := ta.Hooks; h != nil && h.UnwrapPair != nil { - source, target, _ = h.UnwrapPair(source, target) +// planTransformOperation records the recursive calls made by the main +// conversion or one generated function. It reuses a function when that same +// source and target pair is already being converted. +func planTransformOperation(source, target *expr.AttributeExpr, required, topLevel bool, operation *transformOperation, active map[transformPair]TransformHelperID, plan *TransformPlan) error { + return planTransformOperationWithHelper(source, target, required, topLevel, false, operation, active, plan) +} + +// planTransformOperationWithHelper records one conversion. forceHelper is true +// when a custom union renderer declares that it calls TransformHelperName for +// this pair, including named arrays and aliases that the default renderer +// writes inline. +func planTransformOperationWithHelper(source, target *expr.AttributeExpr, required, topLevel, forceHelper bool, operation *transformOperation, active map[transformPair]TransformHelperID, plan *TransformPlan) error { + helperSource, helperTarget := source, target + if forceHelper { + _, sourceNamed := source.Type.(expr.UserType) + _, targetNamed := target.Type.(expr.UserType) + if !sourceNamed && !targetNamed { + return fmt.Errorf("custom union transform helper requires a named source or target type") + } + } + if plan.hooks != nil && plan.hooks.UnwrapPair != nil { + source, target, _ = plan.hooks.UnwrapPair(source, target) + } + if !forceHelper { + helperSource, helperTarget = source, target + } + if err := IsCompatible(source.Type, target.Type, "source", "target"); err != nil { + return err } if topLevel { - req = true - } else { - name := TransformHelperName(source, target, ta) - if _, ok := seen[name]; ok { - return helpers, err - } - if _, ok := source.Type.(expr.UserType); ok && expr.IsObject(source.Type) { - var h *TransformFunctionData - if h, err = generateHelper(source, target, req, ta, seen); h != nil { - helpers = append(helpers, h) - } + required = true + } else if forceHelper || usesTransformHelper(source, target) { + pair := transformPair{ + source: source.Type, + target: target.Type, } + if ancestor, recursive := active[pair]; recursive { + operation.calls = append(operation.calls, transformCall{ + helper: ancestor, + }) + return nil + } + + id := TransformHelperID{plan: plan, index: len(plan.helpers)} + plan.helpers = append(plan.helpers, TransformHelper{ + ID: id, + Source: helperSource, + Target: helperTarget, + Required: required, + Occurrence: id.index + 1, + }) + operation.calls = append(operation.calls, transformCall{ + helper: id, + }) + body := &transformOperation{} + plan.operations = append(plan.operations, body) + active[pair] = id + bodySource, bodyTarget := source, target + if !forceHelper && plan.hooks != nil && plan.hooks.UnwrapPair != nil { + bodySource, bodyTarget, _ = plan.hooks.UnwrapPair(bodySource, bodyTarget) + } + if err := IsCompatible(bodySource.Type, bodyTarget.Type, "source", "target"); err != nil { + delete(active, pair) + return err + } + err := planTransformChildren(bodySource, bodyTarget, required, body, active, plan) + delete(active, pair) + return err + } + return planTransformChildren(source, target, required, operation, active, plan) +} + +// planTransformChildren walks child transformations in the same order as the +// core templates consume helper names. +func planTransformChildren(source, target *expr.AttributeExpr, required bool, operation *transformOperation, active map[transformPair]TransformHelperID, plan *TransformPlan) error { + collect := func(source, target *expr.AttributeExpr, childRequired bool, top bool) error { + return planTransformOperation(source, target, childRequired, top, operation, active, plan) } - // Renderers which inline composite element construction do not call - // element transform helpers: skip helper generation for the elements - // themselves by treating them as top-level attributes. - elemTop := ta.Hooks != nil && ta.Hooks.InlineCompositeElems - var other []*TransformFunctionData + elementTop := plan.hooks != nil && plan.hooks.InlineCompositeElems switch { case expr.IsArray(source.Type): - if other, err = collectHelpers(expr.AsArray(source.Type).ElemType, expr.AsArray(target.Type).ElemType, req, elemTop, ta, seen); err == nil { - helpers = append(helpers, other...) - } + return collect(expr.AsArray(source.Type).ElemType, expr.AsArray(target.Type).ElemType, required, elementTop) case expr.IsMap(source.Type): - sm, tm := expr.AsMap(source.Type), expr.AsMap(target.Type) - if other, err = collectHelpers(sm.ElemType, tm.ElemType, req, elemTop, ta, seen); err == nil { - helpers = append(helpers, other...) - if other, err = collectHelpers(sm.KeyType, tm.KeyType, req, elemTop, ta, seen); err == nil { - helpers = append(helpers, other...) - } + sourceMap, targetMap := expr.AsMap(source.Type), expr.AsMap(target.Type) + if err := collect(sourceMap.KeyType, targetMap.KeyType, required, elementTop); err != nil { + return err } + return collect(sourceMap.ElemType, targetMap.ElemType, required, elementTop) case expr.IsUnion(source.Type): - tt := expr.AsUnion(target.Type) - if tt == nil { - return helpers, err + targetUnion := expr.AsUnion(target.Type) + if targetUnion == nil { + return nil } - for i, st := range expr.AsUnion(source.Type).Values { - if other, err = collectHelpers(st.Attribute, tt.Values[i].Attribute, req, false, ta, seen); err == nil { - helpers = append(helpers, other...) + sourceUnion := expr.AsUnion(source.Type) + if plan.hooks != nil && plan.hooks.TransformUnion != nil { + if plan.hooks.PlanUnionHelpers == nil { + return nil + } + var planErr error + plan.hooks.PlanUnionHelpers(source, target, func(sourceBranch, targetBranch *expr.AttributeExpr) { + if planErr != nil { + return + } + planErr = planTransformOperationWithHelper(sourceBranch, targetBranch, required, false, true, operation, active, plan) + }) + return planErr + } + if len(sourceUnion.Values) != len(targetUnion.Values) { + return fmt.Errorf("cannot transform union: number of union types differ (%s has %d, %s has %d)", + source.Type.Name(), len(sourceUnion.Values), target.Type.Name(), len(targetUnion.Values)) + } + for index, branch := range sourceUnion.Values { + if err := IsCompatible(branch.Attribute.Type, targetUnion.Values[index].Attribute.Type, "source", "target"); err != nil { + return fmt.Errorf("cannot transform union %s to %s: type at index %d: %w", + source.Type.Name(), target.Type.Name(), index, err) + } + } + for index, branch := range sourceUnion.Values { + if err := collect(branch.Attribute, targetUnion.Values[index].Attribute, required, false); err != nil { + return err } } case expr.IsObject(source.Type): if expr.IsUnion(target.Type) { - return helpers, err + return nil } - walkMatches(source, target, func(srcMatt, _ *expr.MappedAttributeExpr, srcc, tgtc *expr.AttributeExpr, n string) { - if err != nil { - return - } - if other, err = collectHelpers(srcc, tgtc, srcMatt.IsRequired(n), false, ta, seen); err == nil { - helpers = append(helpers, other...) + var walkErr error + walkMatches(source, target, func(sourceMapped, _ *expr.MappedAttributeExpr, sourceChild, targetChild *expr.AttributeExpr, name string) { + if walkErr == nil { + if plan.hooks != nil && plan.hooks.FieldPairAttrs != nil { + sourceChild, targetChild = plan.hooks.FieldPairAttrs(sourceChild, targetChild) + } + walkErr = collect(sourceChild, targetChild, sourceMapped.IsRequired(name), false) } }) + return walkErr } - return helpers, err + return nil } -// generateHelper generates the code that transforms instances of source into -// target. Both source and target must be user types. The caller -// (collectHelpers) guarantees no helper was generated yet for the pair. -func generateHelper(source, target *expr.AttributeExpr, req bool, ta *TransformAttrs, seen map[string]*TransformFunctionData) (*TransformFunctionData, error) { - name := TransformHelperName(source, target, ta) +// consume returns the next recursive call recorded by NewTransformPlan. +func (c *transformCallCursor) consume() transformCall { + if c.next >= len(c.calls) { + panic("transform render consumed more helper calls than the plan retained") // bug + } + call := c.calls[c.next] + c.next++ + return call +} - // When transforming into a user type defined in an external package, assume - // nested anonymous types (e.g., union sum types) belong to the same target - // package unless they explicitly specify a different location. Work on a - // copy of the context so the caller's context is never mutated. - if pkg := ta.TargetCtx.Pkg(target); pkg != "" && pkg != ta.TargetCtx.DefaultPkg { - tgtCtx := ta.TargetCtx.Dup() - tgtCtx.DefaultPkg = pkg - tgtCtx.SamePackageConversion = false - ta = &TransformAttrs{SourceCtx: ta.SourceCtx, TargetCtx: tgtCtx, Prefix: ta.Prefix, Hooks: ta.Hooks} +// complete reports whether Render skipped any recorded recursive calls. +func (c *transformCallCursor) complete(owner string) error { + if c.next != len(c.calls) { + return fmt.Errorf("%s rendered %d of %d retained helper calls", owner, c.next, len(c.calls)) } + return nil +} + +// enterTransformAttrs returns a copy that looks up fields and types beneath the +// supplied source and target. +func enterTransformAttrs(source, target *expr.AttributeExpr, attributes *TransformAttrs) *TransformAttrs { + entered := *attributes + entered.SourceCtx = attributes.SourceCtx.Enter(source) + entered.TargetCtx = attributes.TargetCtx.Enter(target) + return &entered +} - code, err := TransformAttribute(source, target, "v", "res", true, ta) +// generateTransformHelper writes one recursive conversion function selected by +// TransformPlan. +func generateTransformHelper(helper TransformHelper, ta *TransformAttrs) (*TransformFunctionData, error) { + code, err := TransformAttribute(helper.Source, helper.Target, "v", "res", true, ta) if err != nil { return nil, err } - if !req && !expr.IsPrimitive(source.Type) { + if !helper.Required && !expr.IsPrimitive(helper.Source.Type) { code = "if v == nil {\n\treturn nil\n}\n" + code } tfd := &TransformFunctionData{ - Name: name, - ParamTypeRef: ta.SourceCtx.Scope.Ref(source, ta.SourceCtx.Pkg(source)), - ResultTypeRef: ta.TargetCtx.Scope.Ref(target, ta.TargetCtx.Pkg(target)), + ID: helper.ID, + Declaration: helper.Declaration, + Name: helper.Declaration.Name(), + ParamTypeRef: ta.SourceCtx.Scope.Ref(helper.Source, ta.SourceCtx.Pkg(helper.Source)), + ResultTypeRef: ta.TargetCtx.Scope.Ref(helper.Target, ta.TargetCtx.Pkg(helper.Target)), Code: code, } - seen[name] = tfd return tfd, nil } @@ -719,11 +1656,25 @@ func generateHelper(source, target *expr.AttributeExpr, req bool, ta *TransformA func walkMatches(source, target *expr.AttributeExpr, walker func(src, tgt *expr.MappedAttributeExpr, srcc, tgtc *expr.AttributeExpr, n string)) { srcMatt := expr.NewMappedAttributeExpr(source) tgtMatt := expr.NewMappedAttributeExpr(target) + srcFields := originalMappedFields(source) + tgtFields := originalMappedFields(target) srcObj := expr.AsObject(srcMatt.Type) tgtObj := expr.AsObject(tgtMatt.Type) for _, nat := range *srcObj { if att := tgtObj.Attribute(nat.Name); att != nil { - walker(srcMatt, tgtMatt, nat.Attribute, att, nat.Name) + walker(srcMatt, tgtMatt, srcFields[nat.Name], tgtFields[nat.Name], nat.Name) } } } + +// originalMappedFields returns each child under the name used for matching. +// The returned children are the values supplied by the caller, not copies. +func originalMappedFields(attribute *expr.AttributeExpr) map[string]*expr.AttributeExpr { + object := expr.AsObject(attribute.Type) + fields := make(map[string]*expr.AttributeExpr, len(*object)) + for _, named := range *object { + name := strings.SplitN(named.Name, ":", 2)[0] + fields[name] = named.Attribute + } + return fields +} diff --git a/codegen/go_transform_hooks.go b/codegen/go_transform_hooks.go index 16432a7f33..a7f9ec612f 100644 --- a/codegen/go_transform_hooks.go +++ b/codegen/go_transform_hooks.go @@ -1,3 +1,6 @@ +// This file lets generators change the few conversion steps that differ from +// Goa's normal Go type conversion. The gRPC generator uses these functions for +// protobuf wrapper messages, fields, collections, unions, and nil checks. package codegen import ( @@ -7,37 +10,23 @@ import ( ) type ( - // TransformHooks are optional extension points consulted by the Go - // transform engine (GoTransformWithAttrs and the functions it drives). - // They let transport-specific generators—today the gRPC generator—alter - // well-defined aspects of the generated transformation code while - // sharing the engine driver (attribute walking, struct initialization, - // nil guards, default value handling and helper function collection). - // - // All fields are optional: a nil hook (or a nil Hooks pointer - // altogether) selects the engine default so that consumers which do not - // set hooks generate exactly the same code as before the hooks were - // introduced. + // TransformHooks lets a generator change specific parts of + // GoTransformWithAttrs. A nil function uses Goa's normal conversion. Hooks + // may inspect their expression arguments but must not mutate or retain them + // for later mutation. NewTransformPlan rejects a planning hook that changes + // its copied expressions. Render rejects later rendering mutations and caches + // each exact Render request, so hook state cannot produce different code when + // that request is repeated. TransformHooks struct { - // UnwrapPair adapts a source/target attribute pair before - // compatibility checks and code generation. It returns the - // attributes to use in place of src and tgt and a non-nil - // WrapDirective when one side referenced a synthetic wrapper - // message that was unwrapped (the gRPC generator wraps - // non-object protobuf message types in single-field messages). - // The engine applies the directive by initializing the wrapper - // and redirecting the source or target variable to the wrapper - // field. UnwrapPair is consulted by TransformAttribute, by - // transformObject for each matched field pair and by - // collectHelpers for each attribute pair it recurses into. + // UnwrapPair replaces a source and target before Goa checks or + // converts them. It also tells Goa whether generated code must + // create or read a protobuf wrapper message. UnwrapPair func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) - // FieldPairAttrs normalizes a matched object field attribute - // pair before the engine generates the field transformation, - // the nil guard and the default value handling. The gRPC - // generator resolves primitive alias user types to their - // underlying primitive attribute. The hook runs before - // UnwrapPair when transforming object fields. + // FieldPairAttrs changes a matched pair after their field names + // are known and before their values are converted. The gRPC + // generator replaces primitive aliases with their primitive + // types. The hook runs before UnwrapPair. FieldPairAttrs func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) // ConvertPrimitive returns the expression that initializes a @@ -65,26 +54,27 @@ type ( // TransformUnion overrides the rendering of union // transformations. srcParent and tgtParent are the attributes // of the object being transformed when the union is an object - // field and nil when the union is transformed directly: the - // gRPC generator derives the protoc-generated oneof wrapper - // type names from the parent message type name. + // field and nil when the union is transformed directly. A + // generator can use them when its union conversion depends on + // the enclosing object. TransformUnion func(source, target *expr.AttributeExpr, sourceVar, targetVar string, newVar bool, srcParent, tgtParent *expr.AttributeExpr, ta *TransformAttrs) (string, error) - // HelperNameAttrs normalizes a source/target attribute pair - // before the transform helper function name is computed. The - // gRPC generator strips struct:pkg:path metadata from the - // protobuf side because protoc-generated types ignore package - // overrides. - HelperNameAttrs func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) + // PlanUnionHelpers lists the helper-name calls made by TransformUnion. + // It is called only when TransformUnion is set. Call record once for each + // source and target branch pair, in the same order that TransformUnion + // calls TransformHelperName. The shared planner writes each helper body + // and records any recursive calls it makes. A nil function means the + // custom union renderer does not call TransformHelperName. + PlanUnionHelpers func(source, target *expr.AttributeExpr, record func(source, target *expr.AttributeExpr)) // GuardCondition returns the condition that guards the // transformation code of an object field, e.g. // "if p.Name != nil {\n". src is the (possibly normalized) // field attribute, srcVar the source field variable, required // reports whether the field is required and srcPtr whether the - // source field is pointer-backed. An empty condition + // source field uses a pointer. An empty condition // with ok true means the field transformation must not be - // guarded. ok must be false to use the engine default policy + // guarded. ok must be false to use Goa's normal check // (the gRPC generator always guards non-primitives because // proto3 message fields are always nilable). GuardCondition func(src *expr.AttributeExpr, srcVar string, required, srcPtr bool) (string, bool) @@ -113,8 +103,8 @@ type ( InlineCompositeElems bool } - // WrapDirective describes how the engine must account for a synthetic - // wrapper message unwrapped by the UnwrapPair hook. + // WrapDirective tells TransformAttribute how to create or read a protobuf + // wrapper message removed by UnwrapPair. WrapDirective struct { // WrapTarget is true when the target attribute was the // wrapper: the engine initializes the wrapper value and @@ -122,19 +112,19 @@ type ( // the source attribute was the wrapper and the engine reads // the value being transformed from the wrapper field. WrapTarget bool - // InitTypeName is the Go type name used to initialize the - // wrapper when WrapTarget is true. - InitTypeName string + // Target is the wrapper type initialized when WrapTarget is true. + // The transform resolves its Go name after package names are fixed. + Target *expr.AttributeExpr // FieldName is the Go name of the wrapper field holding the // wrapped value. FieldName string } ) -// apply rewrites the transformation variables per the directive and returns -// the code that initializes the wrapper when the target is wrapped. A nil -// directive leaves the variables untouched and returns an empty string. -func (d *WrapDirective) apply(sourceVar, targetVar *string, newVar *bool) string { +// apply changes the source or target variable to use the wrapper field. It +// returns the code that creates the wrapper when the target uses one. A nil +// WrapDirective leaves the variables unchanged and returns an empty string. +func (d *WrapDirective) apply(sourceVar, targetVar *string, newVar *bool, attrs *TransformAttrs) string { if d == nil { return "" } @@ -146,7 +136,8 @@ func (d *WrapDirective) apply(sourceVar, targetVar *string, newVar *bool) string if *newVar { assign = ":=" } - code := fmt.Sprintf("%s %s &%s{}\n", *targetVar, assign, d.InitTypeName) + name := attrs.TargetCtx.Scope.Name(d.Target, attrs.TargetCtx.Pkg(d.Target), attrs.TargetCtx.Pointer, attrs.TargetCtx.UseDefault) + code := fmt.Sprintf("%s %s &%s{}\n", *targetVar, assign, name) *targetVar += "." + d.FieldName *newVar = false return code diff --git a/codegen/go_transform_test.go b/codegen/go_transform_test.go index 515c34cbbc..69ccab8b30 100644 --- a/codegen/go_transform_test.go +++ b/codegen/go_transform_test.go @@ -1,6 +1,14 @@ +// This file verifies Go transformations across primitive, composite, named, +// union, service, and transport-owned attribute contexts. package codegen import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -10,6 +18,36 @@ import ( "goa.design/goa/v3/expr" ) +type ( + // transformOwnerAttributor records each nested type name selected while a + // test plans and writes a conversion. + transformOwnerAttributor struct { + prefix string + owner string + scope *NameScope + entered *[]string + } + + // transformIdentityAttributor records the exact attributes passed to field + // name lookups while a plan renders its copied expressions. + transformIdentityAttributor struct { + Attributor + fields *[]*expr.AttributeExpr + } + + // transformTypedValue exercises an accepted object value with a mutable + // exported field. + transformTypedValue struct { + Values []string + } + + // transformPrivateMutableValue cannot be copied without reading private + // mutable state. + transformPrivateMutableValue struct { + values []string + } +) + func TestGoTransform(t *testing.T) { root := RunDSL(t, testdata.TestTypesDSL) var ( @@ -255,3 +293,1341 @@ func TestGoTransformUnionAcrossTransportBoundary(t *testing.T) { require.NotContains(t, transportToService, "scopeValue") require.NotContains(t, transportToService, "target.Scope = &") } + +// TestGoTransformUnionKeepsNilSelectedBranch verifies that conversion leaves a +// selected nil branch for the destination validator to report. +func TestGoTransformUnionKeepsNilSelectedBranch(t *testing.T) { + details := goTypeTestUserType("Details", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + union := &expr.Union{ + TypeName: "State", + Values: []*expr.NamedAttributeExpr{ + {Name: "details", Attribute: &expr.AttributeExpr{Type: details}}, + {Name: "empty", Attribute: &expr.AttributeExpr{Type: expr.Empty}}, + {Name: "aliases", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}}, + {Name: "labels", Attribute: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}}, + {Name: "blob", Attribute: &expr.AttributeExpr{Type: expr.Bytes}}, + {Name: "anything", Attribute: &expr.AttributeExpr{Type: expr.Any}}, + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + } + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, _, err := GoTransform( + &expr.AttributeExpr{Type: union}, + &expr.AttributeExpr{Type: union}, + "source", + "target", + context, + context, + "", + true, + ) + require.NoError(t, err) + code = FormatTestCode(t, "package foo\nfunc transform(){\n"+code+"}") + testutil.AssertGo(t, "testdata/golden/go_transform_union_nil_branch.go.golden", code) +} + +// TestGoTransformRequiredPrimitiveArrayElements verifies that JSON presence +// pointers are removed after validation and added only when encoding that form. +func TestGoTransformRequiredPrimitiveArrayElements(t *testing.T) { + alias := goTypeTestUserType("Alias", expr.String) + array := &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: alias}, + NonNullableElems: true, + }} + scope := NewNameScope() + service := NewAttributeContext(false, false, true, "", scope) + jsonBody := service.Dup() + jsonBody.ArrayElementPointer = true + + decode, _, err := GoTransform(array, array, "source", "target", jsonBody, service, "", true) + require.NoError(t, err) + require.Contains(t, decode, "target := make([]Alias, len(source))") + require.Contains(t, decode, "target[i] = *val") + + encode, _, err := GoTransform(array, array, "source", "target", service, jsonBody, "", true) + require.NoError(t, err) + require.Contains(t, encode, "target := make([]*Alias, len(source))") + require.Contains(t, encode, "var transformed Alias") + require.Contains(t, encode, "target[i] = &transformed") + + ordinary := expr.DupAtt(array) + expr.AsArray(ordinary.Type).NonNullableElems = false + unchanged, _, err := GoTransform(ordinary, ordinary, "source", "target", jsonBody, service, "", true) + require.NoError(t, err) + require.NotContains(t, unchanged, "*val") +} + +// TestGoTransformUsesDesignNilabilityForCustomTypes verifies that default +// handling does not infer comparability from a generated Go type spelling. +func TestGoTransformUsesDesignNilabilityForCustomTypes(t *testing.T) { + raw := &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: json.RawMessage("foo"), + Meta: expr.MetaExpr{ + "struct:field:type": {"json.RawMessage", "encoding/json", "json"}, + }, + } + defaults := goTypeTestUserType("WithRaw", &expr.Object{ + {Name: "raw", Attribute: raw}, + }) + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, _, err := GoTransform( + &expr.AttributeExpr{Type: defaults}, + &expr.AttributeExpr{Type: defaults}, + "source", + "target", + context, + context, + "", + true, + ) + require.NoError(t, err) + require.Contains(t, code, "if target.Raw == nil") + require.NotContains(t, code, "var zero json.RawMessage") + compileTransformSource(t, `package transformtest + +import "encoding/json" + +type WithRaw struct { + Raw json.RawMessage +} + +func transform(source *WithRaw) *WithRaw { +`+code+` + return target +} +`) +} + +// TestGoTransformArrayLoopNameUsesNestingDepth verifies that brackets in a +// caller expression do not change the generated loop variable. +func TestGoTransformArrayLoopNameUsesNestingDepth(t *testing.T) { + array := &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + }} + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, _, err := GoTransform(array, array, "source", "target[index]", context, context, "", false) + require.NoError(t, err) + require.Contains(t, code, "for i, val := range source") + require.NotContains(t, code, "for j, val := range source") +} + +// TestGoTransformUnionTemporaryUsesNestingDepth verifies that a caller's +// destination spelling does not select the local used for a union branch. +func TestGoTransformUnionTemporaryUsesNestingDepth(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "SourceChoice", + Values: []*expr.NamedAttributeExpr{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + }} + target := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "TargetChoice", + Values: []*expr.NamedAttributeExpr{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + }} + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, _, err := GoTransform(source, target, "source", "target.Selected", context, context, "", false) + require.NoError(t, err) + require.Contains(t, code, "obj := actual") + require.NotContains(t, code, "tmp := actual") + + nested, err := transformUnion(source, target, "source", "target.Selected", false, &TransformAttrs{ + SourceCtx: context, + TargetCtx: context, + unionDepth: 1, + }) + require.NoError(t, err) + require.Contains(t, nested, "tmp2 := actual") + require.NotContains(t, nested, "obj := actual") +} + +func TestGoTransformEntersSourceAndTargetOwnersIndependently(t *testing.T) { + source := transformOwnerTestType("SourceEnvelope", "SourceChoice", "source/types") + target := transformOwnerTestType("TargetEnvelope", "TargetSelection", "target/models") + sourceOwner := newTransformOwnerAttributor("source") + targetOwner := newTransformOwnerAttributor("target") + + _, helpers, err := GoTransform( + &expr.AttributeExpr{Type: source}, + &expr.AttributeExpr{Type: target}, + "source", + "target", + &AttributeContext{UseDefault: true, Scope: sourceOwner}, + &AttributeContext{UseDefault: true, Scope: targetOwner}, + "", + true, + ) + require.NoError(t, err) + require.NotEmpty(t, helpers) + require.Contains(t, helpers[0].ParamTypeRef, "sourceSourceChoiceContainer.SourceChoiceContainer") + require.Contains(t, helpers[0].ResultTypeRef, "targetTargetSelectionContainer.TargetSelectionContainer") + require.Contains(t, *sourceOwner.entered, "sourceSourceEnvelope") + require.Contains(t, *sourceOwner.entered, "sourceSourceChoiceContainer") + require.Contains(t, *targetOwner.entered, "targetTargetEnvelope") + require.Contains(t, *targetOwner.entered, "targetTargetSelectionContainer") + + reverseSource := newTransformOwnerAttributor("source") + reverseTarget := newTransformOwnerAttributor("target") + _, reverseHelpers, err := GoTransform( + &expr.AttributeExpr{Type: target}, + &expr.AttributeExpr{Type: source}, + "source", + "target", + &AttributeContext{UseDefault: true, Scope: reverseTarget}, + &AttributeContext{UseDefault: true, Scope: reverseSource}, + "", + true, + ) + require.NoError(t, err) + require.NotEmpty(t, reverseHelpers) + require.Contains(t, reverseHelpers[0].ParamTypeRef, "targetTargetSelectionContainer.TargetSelectionContainer") + require.Contains(t, reverseHelpers[0].ResultTypeRef, "sourceSourceChoiceContainer.SourceChoiceContainer") +} + +// TestGoTransformEntersArrayFieldOwners verifies that a named array element +// is resolved from the array field rather than the object that contains it. +func TestGoTransformEntersArrayFieldOwners(t *testing.T) { + sourceComponent := goTypeTestUserType("SourceComponent", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + targetComponent := goTypeTestUserType("TargetComponent", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + source := goTypeTestUserType("SourceEnvelope", &expr.Object{ + {Name: "components", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: sourceComponent}, + }}}, + }) + target := goTypeTestUserType("TargetEnvelope", &expr.Object{ + {Name: "components", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: targetComponent}, + }}}, + }) + sourceOwner := newTransformOwnerAttributor("source") + targetOwner := newTransformOwnerAttributor("target") + + code, _, err := GoTransform( + &expr.AttributeExpr{Type: source}, + &expr.AttributeExpr{Type: target}, + "source", + "target", + &AttributeContext{UseDefault: true, Scope: sourceOwner}, + &AttributeContext{UseDefault: true, Scope: targetOwner}, + "", + true, + ) + require.NoError(t, err) + require.Contains(t, code, "make([]*targetArray.TargetComponent") + require.Contains(t, *sourceOwner.entered, "sourceArray") + require.Contains(t, *targetOwner.entered, "targetArray") +} + +func TestTransformPlanUsesRetainedHelperIdentityDuringRender(t *testing.T) { + root := RunDSL(t, testdata.TestTypesDSL) + deep := root.UserType("Deep") + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: deep}, + &expr.AttributeExpr{Type: deep}, + "", + nil, + ) + require.NoError(t, err) + + planned := plan.Helpers() + require.Len(t, planned, 2) + declarations := make(map[TransformHelperID]*NameDeclaration, len(planned)) + plannedByID := make(map[TransformHelperID]TransformHelper, len(planned)) + packageCatalog := newGeneratedPackage("test", "example.com/test", "gen") + for index, helper := range planned { + declaration := NewExactName(NameFunction, fmt.Sprintf("canonicalHelper%d", index+1)) + require.NoError(t, packageCatalog.DeclareName(declaration)) + declarations[helper.ID] = declaration + plannedByID[helper.ID] = helper + require.NoError(t, plan.BindHelperDeclaration(helper.ID, declaration)) + } + require.NoError(t, packageCatalog.freeze()) + + attrs := &TransformAttrs{ + SourceCtx: NewAttributeContext(false, false, true, "", NewNameScope()), + TargetCtx: NewAttributeContext(false, false, true, "", NewNameScope()), + } + require.NoError(t, plan.BindContexts(attrs.SourceCtx, attrs.TargetCtx)) + code, helpers, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, helpers, len(planned)) + rendered := code + for index, helper := range helpers { + require.Equal(t, planned[index].ID, helper.ID) + require.Same(t, declarations[helper.ID], helper.Declaration) + require.NotSame(t, plannedByID[helper.ID].Source, plan.Helpers()[index].Source) + require.NotSame(t, plannedByID[helper.ID].Target, plan.Helpers()[index].Target) + require.Equal(t, plannedByID[helper.ID].Source.Type.Name(), plan.Helpers()[index].Source.Type.Name()) + require.Equal(t, plannedByID[helper.ID].Target.Type.Name(), plan.Helpers()[index].Target.Type.Name()) + require.Equal(t, helper.Declaration.Name(), helper.Name) + rendered += helper.Code + } + for _, helper := range helpers { + require.Contains(t, rendered, helper.Declaration.Name()) + } +} + +func TestTransformPlanKeepsDistinctCopiesWithOneOrigin(t *testing.T) { + sourceOrigin := transformObjectAttribute("SourceNode", true).Type.(expr.UserType) + targetOrigin := transformObjectAttribute("TargetNode", true).Type.(expr.UserType) + sourceOuter := sourceOrigin.Dup(nil) + sourceInner := sourceOrigin.Dup(nil) + targetOuter := targetOrigin.Dup(nil) + targetInner := targetOrigin.Dup(nil) + sourceInner.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}) + targetInner.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}) + sourceOuter.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: sourceInner}}, + }}) + targetOuter.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: targetInner}}, + }}) + + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: sourceOuter}}, + }}, + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: targetOuter}}, + }}, + "", + nil, + ) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 2) + require.NotSame(t, plan.Helpers()[0].Source.Type, plan.Helpers()[1].Source.Type) + require.NotSame(t, plan.Helpers()[0].Target.Type, plan.Helpers()[1].Target.Type) + + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 2) + require.Contains(t, code, definitions[0].Declaration.Name()+"(source.Root)") + require.Contains(t, definitions[0].Code, definitions[1].Declaration.Name()+"(v.Child)") +} + +func TestTransformPlanClosesExactRecursiveCycle(t *testing.T) { + sourceNode := &expr.UserTypeExpr{TypeName: "SourceNode"} + targetNode := &expr.UserTypeExpr{TypeName: "TargetNode"} + sourceNode.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "next", Attribute: &expr.AttributeExpr{Type: sourceNode}}, + }}) + targetNode.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "next", Attribute: &expr.AttributeExpr{Type: targetNode}}, + }}) + + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: sourceNode}}, + }}, + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: targetNode}}, + }}, + "", + nil, + ) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 1) + require.Contains(t, code, definitions[0].Declaration.Name()+"(source.Root)") + require.Contains(t, definitions[0].Code, definitions[0].Declaration.Name()+"(v.Next)") +} + +func TestTransformPlanCopiesCallerExpressions(t *testing.T) { + sourceObject := &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + } + targetObject := &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + } + sourceType := &expr.UserTypeExpr{ + TypeName: "Source", + AttributeExpr: &expr.AttributeExpr{Type: sourceObject}, + } + targetType := &expr.UserTypeExpr{ + TypeName: "Target", + AttributeExpr: &expr.AttributeExpr{Type: targetObject}, + } + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: sourceType}, + &expr.AttributeExpr{Type: targetType}, + "", + nil, + ) + require.NoError(t, err) + sourceObject.Set("late", &expr.AttributeExpr{Type: expr.String}) + targetObject.Set("late", &expr.AttributeExpr{Type: expr.String}) + + code, definitions := renderTransformPlan(t, plan) + require.Empty(t, definitions) + require.Contains(t, code, "Value: source.Value") + require.NotContains(t, code, "Late") +} + +func TestTransformPlanCopiesTypedCollectionDefaults(t *testing.T) { + tests := []struct { + name string + dataType expr.DataType + defaultValue any + planned string + changed string + }{ + { + name: "slice", + dataType: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}, + defaultValue: []string{"planned"}, + planned: `[]string{"planned"}`, + changed: `[]string{"changed"}`, + }, + { + name: "string-keyed-map", + dataType: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: expr.Int}, + }, + defaultValue: map[string]int{"value": 1}, + planned: `map[string]int{"value":1}`, + changed: `map[string]int{"value":2}`, + }, + { + name: "integer-keyed-map", + dataType: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.Int}, + ElemType: &expr.AttributeExpr{Type: expr.String}, + }, + defaultValue: map[int]string{1: "planned"}, + planned: `map[int]string{1:"planned"}`, + changed: `map[int]string{1:"changed"}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sourceField := &expr.AttributeExpr{Type: test.dataType} + targetField := &expr.AttributeExpr{Type: test.dataType, DefaultValue: test.defaultValue} + source := &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + TypeName: "Source", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: sourceField}, + }}, + }} + target := &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + TypeName: "Target", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: targetField}, + }}, + }} + plan, err := NewTransformPlan(source, target, "", nil) + require.NoError(t, err) + + switch value := test.defaultValue.(type) { + case []string: + value[0] = "changed" + case map[string]int: + value["value"] = 2 + case map[int]string: + value[1] = "changed" + default: + t.Fatalf("missing mutation for %T", test.defaultValue) + } + code, definitions := renderTransformPlan(t, plan) + require.Empty(t, definitions) + require.Contains(t, code, test.planned) + require.NotContains(t, code, test.changed) + }) + } +} + +func TestCopyTransformValueCopiesNestedTypedShapes(t *testing.T) { + values := []string{"planned"} + object := transformTypedValue{Values: []string{"planned"}} + source := [2]any{&values, object} + copied := copyTransformValue(source).([2]any) + values[0] = "changed" + object.Values[0] = "changed" + + require.Equal(t, "planned", (*copied[0].(*[]string))[0]) + require.Equal(t, "planned", copied[1].(transformTypedValue).Values[0]) +} + +func TestCopyTransformValueRejectsUnsupportedMutableShape(t *testing.T) { + require.PanicsWithValue( + t, + "cannot copy transform value of type chan int", + func() { copyTransformValue(make(chan int)) }, + ) +} + +func TestCopyTransformValueRejectsPrivateMutableField(t *testing.T) { + require.PanicsWithValue( + t, + "cannot copy transform value of type codegen.transformPrivateMutableValue: unexported field values contains mutable data", + func() { copyTransformValue(transformPrivateMutableValue{values: []string{"value"}}) }, + ) +} + +func TestCopyTransformValueRejectsCycle(t *testing.T) { + value := make(map[string]any) + value["self"] = value + require.PanicsWithValue( + t, + "cannot copy cyclic transform value of type map[string]interface {}", + func() { copyTransformValue(value) }, + ) +} + +func TestCopyTransformValueRejectsFunction(t *testing.T) { + require.PanicsWithValue( + t, + "cannot copy transform value of type func()", + func() { copyTransformValue(func() {}) }, + ) +} + +func TestTransformPlanNameLookupsUseOriginalAttributes(t *testing.T) { + sourceField := &expr.AttributeExpr{Type: expr.String} + targetField := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: sourceField}, + }} + target := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: targetField}, + }} + plan, err := NewTransformPlan(source, target, "", nil) + require.NoError(t, err) + + var sourceFields, targetFields []*expr.AttributeExpr + sourceContext := NewAttributeContext(false, false, true, "", NewNameScope()) + sourceContext.Scope = &transformIdentityAttributor{ + Attributor: sourceContext.Scope, + fields: &sourceFields, + } + targetContext := NewAttributeContext(false, false, true, "", NewNameScope()) + targetContext.Scope = &transformIdentityAttributor{ + Attributor: targetContext.Scope, + fields: &targetFields, + } + require.NoError(t, plan.BindContexts(sourceContext, targetContext)) + _, _, err = plan.Render("source", "target", true) + require.NoError(t, err) + require.NotEmpty(t, sourceFields) + require.NotEmpty(t, targetFields) + for _, field := range sourceFields { + require.Same(t, sourceField, field) + } + for _, field := range targetFields { + require.Same(t, targetField, field) + } +} + +func TestTransformPlanCapturesStructuralHookChoices(t *testing.T) { + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }} + var unwrapCalls, fieldCalls int + hooks := &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + unwrapCalls++ + return source, target, nil + }, + FieldPairAttrs: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + fieldCalls++ + return source, target + }, + } + plan, err := NewTransformPlan(attribute, attribute, "", hooks) + require.NoError(t, err) + plannedUnwrapCalls, plannedFieldCalls := unwrapCalls, fieldCalls + require.Positive(t, plannedUnwrapCalls) + require.Positive(t, plannedFieldCalls) + + code, definitions := renderTransformPlan(t, plan) + require.Empty(t, definitions) + require.Contains(t, code, "Value: source.Value") + require.Equal(t, plannedUnwrapCalls, unwrapCalls) + require.Equal(t, plannedFieldCalls, fieldCalls) +} + +func TestTransformPlanRejectsPlanningUnwrapPairMutation(t *testing.T) { + sourceField := &expr.AttributeExpr{Type: expr.String, DefaultValue: "before"} + targetField := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: sourceField}}} + target := &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: targetField}}} + + _, err := NewTransformPlan(source, target, "", &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + if object := expr.AsObject(source.Type); object != nil { + object.Attribute("value").DefaultValue = "after" + } + return source, target, nil + }, + }) + + require.EqualError(t, err, "transform planning hook UnwrapPair changed the retained plan") +} + +func TestTransformPlanRejectsPlanningFieldPairMutation(t *testing.T) { + sourceField := &expr.AttributeExpr{Type: expr.String} + targetField := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: sourceField}}} + target := &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: targetField}}} + + _, err := NewTransformPlan(source, target, "", &TransformHooks{ + FieldPairAttrs: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + target.Meta = expr.MetaExpr{"mutated": {"yes"}} + return source, target + }, + }) + + require.EqualError(t, err, "transform planning hook FieldPairAttrs changed the retained plan") +} + +func TestTransformPlanRejectsPlanningUnionHelperMutation(t *testing.T) { + sourceBranch := &expr.AttributeExpr{Type: expr.String} + targetBranch := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: sourceBranch}}}} + target := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: targetBranch}}}} + + _, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformUnion: func(_ *expr.AttributeExpr, _ *expr.AttributeExpr, _, _ string, _ bool, _, _ *expr.AttributeExpr, _ *TransformAttrs) (string, error) { + return "", nil + }, + PlanUnionHelpers: func(source, target *expr.AttributeExpr, _ func(*expr.AttributeExpr, *expr.AttributeExpr)) { + expr.AsUnion(source.Type).Values[0].Attribute.DefaultValue = "after" + expr.AsUnion(target.Type).Values[0].Attribute.Meta = expr.MetaExpr{"mutated": {"yes"}} + }, + }) + + require.EqualError(t, err, "transform planning hook PlanUnionHelpers changed the retained plan") +} + +func TestGoTransformWithAttrsCallsStructuralHookOnce(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + var calls int + attrs := &TransformAttrs{ + SourceCtx: NewAttributeContext(false, false, true, "", NewNameScope()), + TargetCtx: NewAttributeContext(false, false, true, "", NewNameScope()), + Hooks: &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + calls++ + return source, target, nil + }, + }, + } + + code, helpers, err := GoTransformWithAttrs(attribute, attribute, "source", "target", attrs, true) + require.NoError(t, err) + require.Empty(t, helpers) + require.Equal(t, "target := source", code) + require.Equal(t, 1, calls) +} + +func TestGoTransformWithAttrsRejectsConflictingHelperBodies(t *testing.T) { + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + fields := &expr.Object{} + fields.Set("left", &expr.AttributeExpr{Type: recursive}) + fields.Set("right", &expr.AttributeExpr{Type: recursive}) + container := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: fields, + Validation: &expr.ValidationExpr{Required: []string{"left"}}, + }, + TypeName: "Container", + } + attribute := &expr.AttributeExpr{Type: container} + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + _, _, err := GoTransformWithAttrs(attribute, attribute, "source", "target", &TransformAttrs{ + SourceCtx: context, + TargetCtx: context, + }, true) + require.EqualError(t, err, "transform helper declaration \"transformRecursiveToRecursive\" has different definitions") +} + +func TestTransformPlanUsesCustomUnionHelperOrderForNamedArraysAndAliases(t *testing.T) { + sourceAlias := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "SourceAlias", + } + targetAlias := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "TargetAlias", + } + sourceArray := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}}, + TypeName: "SourceArray", + } + targetArray := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}}, + TypeName: "TargetArray", + } + source := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{ + {Name: "alias", Attribute: &expr.AttributeExpr{Type: sourceAlias}}, + {Name: "array", Attribute: &expr.AttributeExpr{Type: sourceArray}}, + }}} + target := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{ + {Name: "alias", Attribute: &expr.AttributeExpr{Type: targetAlias}}, + {Name: "array", Attribute: &expr.AttributeExpr{Type: targetArray}}, + }}} + hooks := &TransformHooks{ + PlanUnionHelpers: func(source, target *expr.AttributeExpr, record func(*expr.AttributeExpr, *expr.AttributeExpr)) { + sourceUnion, targetUnion := expr.AsUnion(source.Type), expr.AsUnion(target.Type) + record(sourceUnion.Values[1].Attribute, targetUnion.Values[1].Attribute) + record(sourceUnion.Values[0].Attribute, targetUnion.Values[0].Attribute) + }, + TransformUnion: func(source, target *expr.AttributeExpr, _, _ string, _ bool, _, _ *expr.AttributeExpr, attrs *TransformAttrs) (string, error) { + sourceUnion, targetUnion := expr.AsUnion(source.Type), expr.AsUnion(target.Type) + arrayName := TransformHelperName(sourceUnion.Values[1].Attribute, targetUnion.Values[1].Attribute, attrs) + aliasName := TransformHelperName(sourceUnion.Values[0].Attribute, targetUnion.Values[0].Attribute, attrs) + return arrayName + "(source.Array)\n" + aliasName + "(source.Alias)\n", nil + }, + } + plan, err := NewTransformPlan(source, target, "", hooks) + require.NoError(t, err) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + require.Equal(t, "SourceArray", helpers[0].Source.Type.Name()) + require.Equal(t, "SourceAlias", helpers[1].Source.Type.Name()) + arrayDeclaration := NewExactName(NameFunction, "arrayHelper") + aliasDeclaration := NewExactName(NameFunction, "aliasHelper") + generatedPackage := newGeneratedPackage("test", "example.com/test", "gen") + require.NoError(t, generatedPackage.DeclareName(arrayDeclaration)) + require.NoError(t, generatedPackage.DeclareName(aliasDeclaration)) + require.NoError(t, plan.BindHelperDeclaration(helpers[0].ID, arrayDeclaration)) + require.NoError(t, plan.BindHelperDeclaration(helpers[1].ID, aliasDeclaration)) + require.NoError(t, generatedPackage.freeze()) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + + code, definitions, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, definitions, 2) + require.Contains(t, code, "arrayHelper") + require.Contains(t, code, "aliasHelper") + require.Less(t, strings.Index(code, "arrayHelper"), strings.Index(code, "aliasHelper")) +} + +func TestTransformPlanCapturesUnwrappedHelperBody(t *testing.T) { + sourceNode := transformObjectAttribute("SourceNode", true) + targetNode := transformObjectAttribute("TargetNode", true) + wrapper := &expr.UserTypeExpr{ + TypeName: "TargetWrapper", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "field", Attribute: targetNode}, + }}, + } + source := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: sourceNode}, + }} + target := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: wrapper}}, + }} + hooks := &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + if target.Type.Name() != wrapper.TypeName { + return source, target, nil + } + return source, expr.AsObject(target.Type).Attribute("field"), &WrapDirective{ + WrapTarget: true, + Target: target, + FieldName: "Field", + } + }, + } + plan, err := NewTransformPlan(source, target, "", hooks) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 1) + require.Contains(t, code, definitions[0].Declaration.Name()+"(source.Root)") +} + +func TestTransformPlanRetainsWrapperAndInlineArrayCalls(t *testing.T) { + node := &expr.UserTypeExpr{TypeName: "Node"} + node.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{}} + expr.AsObject(node.Type).Set("next", &expr.AttributeExpr{Type: node}) + source := &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: node}}} + target := expr.DupAtt(source) + hooks := &TransformHooks{ + InlineCompositeElems: true, + TransformArray: func(source, target *expr.Array, sourceVar, targetVar string, newVar bool, attrs *TransformAttrs) (string, error) { + return TransformAttribute(source.ElemType, target.ElemType, sourceVar+"[0]", targetVar+"[0]", newVar, attrs) + }, + } + plan, err := NewTransformPlan(source, target, "copied", hooks) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + + declaration := NewExactName(NameFunction, "copyNode") + packageCatalog := newGeneratedPackage("test", "example.com/test", "gen") + require.NoError(t, packageCatalog.DeclareName(declaration)) + require.NoError(t, plan.BindHelperDeclaration(plan.Helpers()[0].ID, declaration)) + require.NoError(t, packageCatalog.freeze()) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + code, helpers, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, helpers, 1) + require.Contains(t, code+helpers[0].Code, "copyNode") + + wrapper := &expr.UserTypeExpr{ + TypeName: "WrappedNode", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: "field", Attribute: &expr.AttributeExpr{Type: node}}, + }}, + } + wrapperTarget := &expr.AttributeExpr{Type: wrapper} + wrapperHooks := &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + if target.Type.Name() != wrapper.TypeName { + return source, target, nil + } + return source, expr.AsObject(target.Type).Attribute("field"), &WrapDirective{ + WrapTarget: true, + Target: target, + FieldName: "Field", + } + }, + } + wrapperPlan, err := NewTransformPlan(&expr.AttributeExpr{Type: node}, wrapperTarget, "wrapped", wrapperHooks) + require.NoError(t, err) + require.Len(t, wrapperPlan.Helpers(), 1) + wrapperDeclaration := NewExactName(NameFunction, "copyWrappedNode") + wrapperPackage := newGeneratedPackage("wrapper", "example.com/wrapper", "gen") + require.NoError(t, wrapperPackage.DeclareName(wrapperDeclaration)) + require.NoError(t, wrapperPlan.BindHelperDeclaration(wrapperPlan.Helpers()[0].ID, wrapperDeclaration)) + require.NoError(t, wrapperPackage.freeze()) + require.NoError(t, wrapperPlan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + wrapperCode, _, err := wrapperPlan.Render("source", "target", true) + require.NoError(t, err) + require.Contains(t, wrapperCode, "target := &WrappedNode{}") + require.Contains(t, wrapperCode, "target.Field") +} + +func TestTransformPlanRetainsSameTypeSiblingOccurrences(t *testing.T) { + plan := siblingTransformPlan(t) + require.Len(t, plan.Helpers(), 2) + require.NotEqual(t, plan.Helpers()[0].ID, plan.Helpers()[1].ID) +} + +func TestTransformPlanHelperDescriptionsCannotChangeRenderGraph(t *testing.T) { + plan := siblingTransformPlan(t) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + + // Helper descriptions are used to choose declarations. Mutating one must + // never alter the private attributes that Render uses to write those + // declarations. + helpers[0].Source.Type = expr.String + + _, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 2) + require.Equal(t, "*Recursive", definitions[0].ParamTypeRef) +} + +func TestTransformPlanRejectsRenderHookMutation(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}} + target := expr.DupAtt(source) + plan, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformArray: func(source, _ *expr.Array, _, _ string, _ bool, _ *TransformAttrs) (string, error) { + source.ElemType.Type = expr.Int + return "", nil + }, + }) + require.NoError(t, err) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + + _, _, err = plan.Render("source", "target", true) + require.EqualError(t, err, "transform render hook changed the retained plan") +} + +func TestTransformPlanCachesResultAgainstMutationRetainedByRenderHook(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}} + target := expr.DupAtt(source) + var retained *expr.Array + plan, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformArray: func(source, _ *expr.Array, _, _ string, _ bool, _ *TransformAttrs) (string, error) { + retained = source + return "", nil + }, + }) + require.NoError(t, err) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + _, _, err = plan.Render("source", "target", true) + require.NoError(t, err) + + retained.ElemType.Type = expr.Int + _, _, err = plan.Render("source", "target", true) + require.NoError(t, err) +} + +func TestTransformPlanCachesRepeatedRender(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}} + target := expr.DupAtt(source) + var renders int + plan, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformArray: func(_ *expr.Array, _ *expr.Array, _, _ string, _ bool, _ *TransformAttrs) (string, error) { + renders++ + return fmt.Sprintf("render%d", renders), nil + }, + }) + require.NoError(t, err) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + + code, _, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Equal(t, "render1", code) + code, _, err = plan.Render("source", "target", true) + require.NoError(t, err) + require.Equal(t, "render1", code) + require.Equal(t, 1, renders) +} + +func TestTransformPlanSharesOneDeclarationForEquivalentHelpers(t *testing.T) { + plan := siblingTransformPlan(t) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + declaration := NewExactName(NameFunction, "transformRecursive") + pkg := newGeneratedPackage("test", "example.com/test", "gen") + require.NoError(t, pkg.DeclareName(declaration)) + require.NoError(t, pkg.freeze()) + + require.NoError(t, plan.BindHelperDeclaration(helpers[0].ID, declaration)) + require.NoError(t, plan.BindHelperDeclaration(helpers[1].ID, declaration)) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + code, definitions, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, definitions, 1) + require.Contains(t, code, "target.Left = transformRecursive(source.Left)") + require.Contains(t, code, "target.Right = transformRecursive(source.Right)") +} + +func TestTransformPlanRejectsSharedDeclarationForDifferentBehavior(t *testing.T) { + plan := mixedSiblingTransformPlan(t) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + declaration := NewExactName(NameFunction, "transformRecursive") + pkg := newGeneratedPackage("test", "example.com/test", "gen") + require.NoError(t, pkg.DeclareName(declaration)) + require.NoError(t, pkg.freeze()) + + require.NoError(t, plan.BindHelperDeclaration(helpers[0].ID, declaration)) + require.NoError(t, plan.BindHelperDeclaration(helpers[1].ID, declaration)) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + _, _, err := plan.Render("source", "target", true) + require.EqualError(t, err, "transform helper declaration \"transformRecursive\" has different definitions") +} + +func TestTransformPlanRequiresEveryHelperDeclaration(t *testing.T) { + plan := siblingTransformPlan(t) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + require.NoError(t, plan.BindHelperDeclaration( + helpers[0].ID, + NewExactName(NameFunction, "transformLeftRecursive"), + )) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + + _, _, err := plan.Render("source", "target", true) + require.EqualError(t, err, "transform helper occurrence 2 has no declaration") +} + +func TestTransformPlanBindsContextsOnce(t *testing.T) { + plan := siblingTransformPlan(t) + source := NewAttributeContext(false, false, true, "", NewNameScope()) + target := NewAttributeContext(false, false, true, "", NewNameScope()) + require.NoError(t, plan.BindContexts(source, target)) + require.EqualError(t, plan.BindContexts(source, target), "transform contexts are already bound") +} + +func TestTransformPlanRejectsNonFunctionHelperDeclaration(t *testing.T) { + plan := siblingTransformPlan(t) + err := plan.BindHelperDeclaration( + plan.Helpers()[0].ID, + NewExactName(NameType, "TransformRecursive"), + ) + require.EqualError(t, err, "transform helper declaration must be a function, got type") +} + +func TestTransformPlanHelperEligibilityMatchesCompositeRenderers(t *testing.T) { + shapes := map[string]func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr){ + "array": func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + return &expr.AttributeExpr{Type: &expr.Array{ElemType: source}}, + &expr.AttributeExpr{Type: &expr.Array{ElemType: target}} + }, + "map": func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + return &expr.AttributeExpr{Type: &expr.Map{KeyType: &expr.AttributeExpr{Type: expr.String}, ElemType: source}}, + &expr.AttributeExpr{Type: &expr.Map{KeyType: &expr.AttributeExpr{Type: expr.String}, ElemType: target}} + }, + "union": func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + return &expr.AttributeExpr{Type: &expr.Union{TypeName: "SourceChoice", Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: source}}}}, + &expr.AttributeExpr{Type: &expr.Union{TypeName: "TargetChoice", Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: target}}}} + }, + } + pairs := map[string]struct { + source, target *expr.AttributeExpr + helpers int + }{ + "both-named": { + source: transformObjectAttribute("SourceNode", true), + target: transformObjectAttribute("TargetNode", true), + helpers: 1, + }, + "anonymous-source": { + source: transformObjectAttribute("", false), + target: transformObjectAttribute("TargetNode", true), + }, + "anonymous-target": { + source: transformObjectAttribute("SourceNode", true), + target: transformObjectAttribute("", false), + }, + } + + for shapeName, shape := range shapes { + for pairName, pair := range pairs { + t.Run(shapeName+"/"+pairName, func(t *testing.T) { + source, target := shape(pair.source, pair.target) + plan, err := NewTransformPlan(source, target, "", nil) + require.NoError(t, err) + require.Len(t, plan.Helpers(), pair.helpers) + + code, helpers := renderTransformPlan(t, plan) + require.Len(t, helpers, pair.helpers) + if pair.helpers == 0 { + require.NotContains(t, code, "canonicalHelper") + } else { + require.Contains(t, code, "canonicalHelper1") + } + }) + } + } +} + +func TestTransformPlanRetainsRequiredAndOptionalSiblingCalls(t *testing.T) { + plan := mixedSiblingTransformPlan(t) + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 2) + + var required, optional *TransformFunctionData + for _, definition := range definitions { + if plan.Helpers()[definition.ID.index].Required { + required = definition + } else { + optional = definition + } + } + require.NotNil(t, required) + require.NotNil(t, optional) + require.NotContains(t, required.Code, "if v == nil") + require.Contains(t, optional.Code, "if v == nil") + require.Contains(t, code, "target.Left = "+required.Declaration.Name()+"(source.Left)") + require.Contains(t, code, "target.Right = "+optional.Declaration.Name()+"(source.Right)") +} + +func TestTransformPlanMapKeyHelperReceivesKey(t *testing.T) { + sourceKey := transformObjectAttribute("SourceKey", true) + targetKey := transformObjectAttribute("TargetKey", true) + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Map{ + KeyType: sourceKey, + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}, + &expr.AttributeExpr{Type: &expr.Map{ + KeyType: targetKey, + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}, + "", + nil, + ) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + require.NotSame(t, sourceKey, plan.Helpers()[0].Source) + require.NotSame(t, targetKey, plan.Helpers()[0].Target) + require.Equal(t, sourceKey.Type.Name(), plan.Helpers()[0].Source.Type.Name()) + require.Equal(t, targetKey.Type.Name(), plan.Helpers()[0].Target.Type.Name()) + + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 1) + bound := plan.Helpers()[0] + require.Equal(t, bound.ID, definitions[0].ID) + require.Same(t, bound.Declaration, definitions[0].Declaration) + + generated := fmt.Sprintf(`package transformtest + +type SourceKey struct { + Value string +} + +type TargetKey struct { + Value string +} + +func transform(source map[*SourceKey]string) map[*TargetKey]string { +%s + return target +} + +func %s(v %s) %s { +%s + return res +} +`, code, definitions[0].Declaration.Name(), definitions[0].ParamTypeRef, + definitions[0].ResultTypeRef, definitions[0].Code) + compileTransformSource(t, generated) + require.Contains(t, code, definitions[0].Declaration.Name()+"(key)") +} + +// siblingTransformPlan builds two nonrecursive occurrences of the same named +// recursive type. Each field must own a helper even though both types share an +// authored origin. +func siblingTransformPlan(t *testing.T) *TransformPlan { + t.Helper() + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + fields := &expr.Object{} + fields.Set("left", &expr.AttributeExpr{Type: recursive}) + fields.Set("right", &expr.AttributeExpr{Type: recursive}) + container := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: fields}, + TypeName: "Container", + } + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: container}, + &expr.AttributeExpr{Type: container}, + "", + nil, + ) + require.NoError(t, err) + return plan +} + +// mixedSiblingTransformPlan builds required and optional occurrences of the +// same recursive named type in one transform operation. +func mixedSiblingTransformPlan(t *testing.T) *TransformPlan { + t.Helper() + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + fields := &expr.Object{} + fields.Set("left", &expr.AttributeExpr{Type: recursive}) + fields.Set("right", &expr.AttributeExpr{Type: recursive}) + container := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: fields, + Validation: &expr.ValidationExpr{Required: []string{"left"}}, + }, + TypeName: "Container", + } + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: container}, + &expr.AttributeExpr{Type: container}, + "", + nil, + ) + require.NoError(t, err) + return plan +} + +// transformObjectAttribute builds either a named or anonymous object with the +// same compatible field shape. +func transformObjectAttribute(name string, named bool) *expr.AttributeExpr { + object := &expr.Object{} + object.Set("value", &expr.AttributeExpr{Type: expr.String}) + if !named { + return &expr.AttributeExpr{Type: object} + } + return &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: object}, + TypeName: name, + }} +} + +// renderTransformPlan assigns fixed function declarations and contexts, then +// renders the stored operation and definitions. +func renderTransformPlan(t *testing.T, plan *TransformPlan) (string, []*TransformFunctionData) { + t.Helper() + packageCatalog := newGeneratedPackage("test", "example.com/test", "gen") + for index, helper := range plan.Helpers() { + declaration := NewExactName(NameFunction, fmt.Sprintf("canonicalHelper%d", index+1)) + require.NoError(t, packageCatalog.DeclareName(declaration)) + require.NoError(t, plan.BindHelperDeclaration(helper.ID, declaration)) + } + require.NoError(t, packageCatalog.freeze()) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + code, helpers, err := plan.Render("source", "target", true) + require.NoError(t, err) + return code, helpers +} + +// compileTransformSource proves that a rendered transform and its stored +// helper definitions agree on concrete Go argument and result types. +func compileTransformSource(t *testing.T, source string) { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "go.mod"), + []byte("module example.com/transformtest\n\ngo 1.25.0\n"), + 0o600, + )) + require.NoError(t, os.WriteFile(filepath.Join(dir, "transform.go"), []byte(source), 0o600)) + command := exec.Command("go", "test", "./...") + command.Dir = dir + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("generated transform did not compile: %v\n%s", err, output) + } +} + +// newTransformOwnerAttributor creates a test type-name provider and records +// every nested type it enters. +func newTransformOwnerAttributor(prefix string) *transformOwnerAttributor { + entered := make([]string, 0) + return &transformOwnerAttributor{ + prefix: prefix, + scope: NewNameScope(), + entered: &entered, + } +} + +func (a *transformOwnerAttributor) Name(att *expr.AttributeExpr, _ string, _, _ bool) string { + return a.owner + "." + codegenTypeName(att) +} + +func (a *transformOwnerAttributor) Ref(att *expr.AttributeExpr, pkg string) string { + name := a.Name(att, pkg, false, false) + if expr.IsObject(att.Type) || expr.IsUnion(att.Type) { + return "*" + name + } + return name +} + +func (*transformOwnerAttributor) Field(_ *expr.AttributeExpr, name string, firstUpper bool) string { + return Goify(name, firstUpper) +} + +func (a *transformOwnerAttributor) Package(_ *expr.AttributeExpr) string { + return a.owner +} + +func (a *transformOwnerAttributor) Enter(att *expr.AttributeExpr) Attributor { + entered := *a + entered.owner = a.prefix + codegenTypeName(att) + *a.entered = append(*a.entered, entered.owner) + return &entered +} + +func (*transformOwnerAttributor) IsSumType() bool { + return true +} + +func (a *transformOwnerAttributor) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + name := "Validate" + a.Name(att, "", false, true) + Goify(view, true) + return fmt.Sprintf("%s(%s)", name, target) +} + +func (a *transformOwnerAttributor) Scope() *NameScope { + return a.scope +} + +// Field records the expression identity before delegating the field name. +func (a *transformIdentityAttributor) Field(attribute *expr.AttributeExpr, name string, firstUpper bool) string { + *a.fields = append(*a.fields, attribute) + return a.Attributor.Field(attribute, name, firstUpper) +} + +// Enter keeps recording identities below the supplied object. +func (a *transformIdentityAttributor) Enter(attribute *expr.AttributeExpr) Attributor { + return &transformIdentityAttributor{ + Attributor: a.Attributor.Enter(attribute), + fields: a.fields, + } +} + +// transformOwnerTestType builds a nested union type assigned to the requested +// generated package. +func transformOwnerTestType(name, unionName, location string) expr.UserType { + union := &expr.Union{ + TypeName: unionName, + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "number", Attribute: &expr.AttributeExpr{Type: expr.Int}}, + }, + } + container := &expr.UserTypeExpr{ + TypeName: unionName + "Container", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "choice", Attribute: &expr.AttributeExpr{Type: union}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {location}}, + }, + } + return &expr.UserTypeExpr{ + TypeName: name, + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "inner", Attribute: &expr.AttributeExpr{Type: container}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {location}}, + }, + } +} + +// codegenTypeName returns the Go name used by transformOwnerAttributor for one +// test type. +func codegenTypeName(att *expr.AttributeExpr) string { + if att.Type.Name() == "object" { + return "Object" + } + return Goify(att.Type.Name(), true) +} diff --git a/codegen/go_type_plan.go b/codegen/go_type_plan.go new file mode 100644 index 0000000000..6dce0004a0 --- /dev/null +++ b/codegen/go_type_plan.go @@ -0,0 +1,743 @@ +// This file records each Go type before generated package names are final. +// Formatting later uses the copied field names, package paths, and child types +// without reading the Goa expressions again. +package codegen + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "goa.design/goa/v3/expr" +) + +type ( + // GoTypeKind states how a planned value is represented in Go. + GoTypeKind uint8 + + // GoTypeImport describes one package used in a planned Go type. + GoTypeImport struct { + // Name is the package name requested before the type, when present. + Name string + // Path is the Go import path. + Path string + } + + // GoTypeBindingRequest asks which generated declaration and package contain + // one attribute's named type or union. + GoTypeBindingRequest struct { + // Attribute is the expression being planned. + Attribute *expr.AttributeExpr + // InheritedOwner is the package path inherited from the enclosing type. + InheritedOwner string + // Kind says whether Attribute contains a named type or a union. + Kind GoTypeKind + } + + // GoTypeBinding gives a planned attribute the package path and generated + // declaration that will represent it. A named type sets Type, and a union + // sets Union. + GoTypeBinding struct { + // Owner is the import path of the package containing the declaration. + Owner string + // Type is the generated declaration for a named user type. + Type *TypeDeclaration + // Union is the generated declaration for a union. + Union *UnionDeclaration + } + + // GoTypeBinder returns the package path and generated declaration for a named + // type or union. PlanGoType does not choose these values itself. + GoTypeBinder func(GoTypeBindingRequest) (GoTypeBinding, error) + + // GoLayoutPolicy contains the pointer and validation choices used throughout + // one planned Go type. + GoLayoutPolicy struct { + // Pointer forces primitive object fields to use pointers. + Pointer bool + // IgnoreRequired suppresses required checks for primitive transport fields. + IgnoreRequired bool + // UseDefault keeps optional primitive fields with defaults as values. + UseDefault bool + // UnionPointer uses pointers for optional sum-type union fields and for + // required union fields when Pointer is also true. + UnionPointer bool + // ArrayElementPointer uses pointers for required primitive array elements + // when generated input validation must distinguish null from a zero value. + ArrayElementPointer bool + // SumType reports whether unions use Goa's generated struct form. + SumType bool + } + + // GoTypePlanOptions supplies the package, field name, and rules used to plan + // one attribute. + GoTypePlanOptions struct { + // Owner is the package path that will contain the top-level attribute. + Owner string + // FieldName is the design field name of the top-level attribute, when set. + FieldName string + // Policy contains the pointer and validation choices selected by the caller. + Policy GoLayoutPolicy + // Bind returns the generated declaration for every named type and union. + Bind GoTypeBinder + } + + // GoTypePlan stores the complete Go form copied from one attribute. It keeps + // expression pointers only so callers can find which plans came from the same + // attribute; its methods do not read those expressions. + GoTypePlan struct { + kind GoTypeKind + owner string + policy GoLayoutPolicy + occurrence *expr.AttributeExpr + fieldNameUpper string + fieldNameLower string + description string + comment string + tag string + fieldPointer bool + definitionPointer bool + referencePointer bool + primitive string + directImport GoTypeImport + hasDirectImport bool + customQualifier string + typeDeclaration *TypeDeclaration + unionDeclaration *UnionDeclaration + fields []*GoTypePlan + branches []*GoTypePlan + element *GoTypePlan + key *GoTypePlan + } + + // GoTypeQualifier returns the final package name written before a type from + // the given import path. + GoTypeQualifier func(importPath string) string + + // LinkedGoType formats a planned type for one output package after all type + // names and imported package names are final. + LinkedGoType struct { + plan *GoTypePlan + outputPath string + qualifier GoTypeQualifier + } + + // goTypePlanner reads attributes and builds GoTypePlan values. + goTypePlanner struct { + policy GoLayoutPolicy + bind GoTypeBinder + } +) + +const ( + // GoPrimitive is a built-in or explicitly imported primitive type. + GoPrimitive GoTypeKind = iota + 1 + // GoArray is a slice with one planned element type. + GoArray + // GoMap is a map with planned key and element types. + GoMap + // GoStruct is an anonymous struct with fields in source order. + GoStruct + // GoNamed is a user type with a generated type declaration. + GoNamed + // GoUnion is a union with a generated union declaration. + GoUnion + // GoEmpty is Goa's built-in empty service type. + GoEmpty + // GoServiceError is Goa's built-in service error type. + GoServiceError +) + +// PlanGoType copies the Go form of attribute before generated type and imported +// package names are final. Callers format the result after Generation.Freeze +// chooses those names. +func PlanGoType(attribute *expr.AttributeExpr, options GoTypePlanOptions) (*GoTypePlan, error) { + if attribute == nil { + return nil, fmt.Errorf("plan Go type: attribute must not be nil") + } + if options.Owner == "" { + return nil, fmt.Errorf("plan Go type: inherited owner must not be empty") + } + planner := goTypePlanner{ + policy: options.Policy, + bind: options.Bind, + } + return planner.plan(attribute, options.Owner, options.FieldName, nil, false) +} + +// String returns the name of the Go type kind used in error messages. +func (k GoTypeKind) String() string { + switch k { + case GoPrimitive: + return "primitive" + case GoArray: + return "array" + case GoMap: + return "map" + case GoStruct: + return "struct" + case GoNamed: + return "named type" + case GoUnion: + return "union" + case GoEmpty: + return "empty type" + case GoServiceError: + return "service error" + default: + return "unknown" + } +} + +// Kind returns how this planned value is represented in Go. +func (p *GoTypePlan) Kind() GoTypeKind { + return p.kind +} + +// Owner returns the import path of the package containing this type. +func (p *GoTypePlan) Owner() string { + return p.owner +} + +// Policy returns the pointer and validation choices used for this type. +func (p *GoTypePlan) Policy() GoLayoutPolicy { + return p.policy +} + +// MatchesOccurrence reports whether PlanGoType built this plan from attribute. +// It compares pointers without reading the expression. +func (p *GoTypePlan) MatchesOccurrence(attribute *expr.AttributeExpr) bool { + return p.occurrence == attribute +} + +// PlansForOccurrence returns every child plan built from attribute. The same +// attribute may produce several plans with different field names, package +// paths, or pointer choices. +func (p *GoTypePlan) PlansForOccurrence(attribute *expr.AttributeExpr) []*GoTypePlan { + var matches []*GoTypePlan + p.walk(func(candidate *GoTypePlan) { + if candidate.occurrence == attribute { + matches = append(matches, candidate) + } + }) + return matches +} + +// TypeDeclaration returns the generated declaration for a named user type. It +// returns nil for every other kind. +func (p *GoTypePlan) TypeDeclaration() *TypeDeclaration { + return p.typeDeclaration +} + +// UnionDeclaration returns the generated declaration for a union. It returns +// nil for every other kind. +func (p *GoTypePlan) UnionDeclaration() *UnionDeclaration { + return p.unionDeclaration +} + +// FieldName returns the copied Go field name. It returns an exported name when +// firstUpper is true and an unexported name otherwise. +func (p *GoTypePlan) FieldName(firstUpper bool) string { + if firstUpper { + return p.fieldNameUpper + } + return p.fieldNameLower +} + +// Description returns the description copied from the attribute. +func (p *GoTypePlan) Description() string { + return p.description +} + +// Tag returns the complete copied Go struct tag, including its leading space. +func (p *GoTypePlan) Tag() string { + return p.tag +} + +// IsPointer reports whether an enclosing struct stores this value through a +// pointer under the selected pointer and default rules. +func (p *GoTypePlan) IsPointer() bool { + return p.fieldPointer +} + +// Import returns the package written directly in this type name. The second +// result is false for built-in types and generated declarations. +func (p *GoTypePlan) Import() (GoTypeImport, bool) { + return p.directImport, p.hasDirectImport +} + +// ImportPreferences returns each requested imported package name and each +// generated type package found in this plan, in field order. It keeps different +// requested names for the same path so Generation can choose the final name. +func (p *GoTypePlan) ImportPreferences() []GoTypeImport { + seen := make(map[GoTypeImport]struct{}) + var imports []GoTypeImport + p.walkImports(func(candidate *GoTypePlan) { + var goImport GoTypeImport + switch { + case candidate.hasDirectImport: + goImport = candidate.directImport + case candidate.typeDeclaration != nil || candidate.unionDeclaration != nil: + goImport = GoTypeImport{Path: candidate.owner} + default: + return + } + if _, exists := seen[goImport]; exists { + return + } + seen[goImport] = struct{}{} + imports = append(imports, goImport) + }) + return imports +} + +// Fields returns a copy of the anonymous struct fields in source order. +func (p *GoTypePlan) Fields() []*GoTypePlan { + return append([]*GoTypePlan(nil), p.fields...) +} + +// Branches returns a copy of the union branches in source order. +func (p *GoTypePlan) Branches() []*GoTypePlan { + return append([]*GoTypePlan(nil), p.branches...) +} + +// Elem returns the planned array or map element type. It returns nil for other +// kinds. +func (p *GoTypePlan) Elem() *GoTypePlan { + return p.element +} + +// Key returns the planned map key type. It returns nil for other kinds. +func (p *GoTypePlan) Key() *GoTypePlan { + return p.key +} + +// Equivalent reports whether p and other produce the same Go type. It compares +// declarations, package paths, pointer choices, field names, tags, imports, and +// child types, but does not compare source expression pointers. +func (p *GoTypePlan) Equivalent(other *GoTypePlan) bool { + if p == nil || other == nil { + return p == other + } + if p.kind != other.kind || p.owner != other.owner || p.policy != other.policy || + p.fieldNameUpper != other.fieldNameUpper || p.fieldNameLower != other.fieldNameLower || + p.description != other.description || p.comment != other.comment || p.tag != other.tag || + p.fieldPointer != other.fieldPointer || p.definitionPointer != other.definitionPointer || + p.referencePointer != other.referencePointer || p.primitive != other.primitive || + p.directImport != other.directImport || p.hasDirectImport != other.hasDirectImport || + p.customQualifier != other.customQualifier || p.typeDeclaration != other.typeDeclaration || + p.unionDeclaration != other.unionDeclaration || len(p.fields) != len(other.fields) || + len(p.branches) != len(other.branches) { + return false + } + if !p.key.Equivalent(other.key) || !p.element.Equivalent(other.element) { + return false + } + for index := range p.fields { + if !p.fields[index].Equivalent(other.fields[index]) { + return false + } + } + for index := range p.branches { + if !p.branches[index].Equivalent(other.branches[index]) { + return false + } + } + return true +} + +// Link prepares this plan for formatting in outputPath after generated type and +// imported package names are final. The returned value uses only data already +// copied into the plan. +func (p *GoTypePlan) Link(outputPath string, qualifier GoTypeQualifier) LinkedGoType { + return LinkedGoType{plan: p, outputPath: outputPath, qualifier: qualifier} +} + +// Name returns the Go type name selected by the plan. +func (l LinkedGoType) Name() string { + switch l.plan.kind { + case GoPrimitive: + if !l.plan.hasDirectImport || l.plan.customQualifier == "" { + return l.plan.primitive + } + return strings.ReplaceAll( + l.plan.primitive, + l.plan.customQualifier+".", + l.qualify(l.plan.directImport.Path)+".", + ) + case GoArray: + return "[]" + l.Enter(l.plan.element).Ref() + case GoMap: + return fmt.Sprintf( + "map[%s]%s", + l.Enter(l.plan.key).Ref(), + l.Enter(l.plan.element).Ref(), + ) + case GoStruct: + return l.Def() + case GoNamed: + return l.qualifiedDeclaration(l.plan.typeDeclaration.Declaration()) + case GoUnion: + return l.qualifiedDeclaration(l.plan.unionDeclaration.Declaration()) + case GoEmpty: + return "struct {}" + case GoServiceError: + return l.qualify(l.plan.directImport.Path) + ".ServiceError" + default: + panic(fmt.Sprintf("format unknown retained Go type kind %d", l.plan.kind)) + } +} + +// Def returns the complete Go type definition selected by the plan. +func (l LinkedGoType) Def() string { + switch l.plan.kind { + case GoArray: + element := l.Enter(l.plan.element).Def() + if l.plan.element.definitionPointer { + element = "*" + element + } + return "[]" + element + case GoMap: + key := l.Enter(l.plan.key).Def() + if l.plan.key.definitionPointer { + key = "*" + key + } + element := l.Enter(l.plan.element).Def() + if l.plan.element.definitionPointer { + element = "*" + element + } + return fmt.Sprintf("map[%s]%s", key, element) + case GoStruct: + lines := []string{"struct {"} + for _, field := range l.plan.fields { + fieldType := l.Enter(field).Def() + if field.fieldPointer { + fieldType = "*" + fieldType + } + var description string + if field.comment != "" { + description = field.comment + "\n\t" + } + lines = append(lines, fmt.Sprintf( + "\t%s%s %s%s", + description, + field.fieldNameUpper, + fieldType, + field.tag, + )) + } + return strings.Join(append(lines, "}"), "\n") + default: + return l.Name() + } +} + +// Ref returns the Go type reference, including any pointer required for a named +// object or union. +func (l LinkedGoType) Ref() string { + name := l.Name() + if l.plan.referencePointer { + return "*" + name + } + return name +} + +// Field returns the copied Go field name for this planned value. +func (l LinkedGoType) Field(firstUpper bool) string { + return l.plan.FieldName(firstUpper) +} + +// Package returns the package name written before this type when referenced +// from the output package. It returns an empty string when both types are in the +// same package. +func (l LinkedGoType) Package() string { + if l.plan.owner == l.outputPath { + return "" + } + return l.qualify(l.plan.owner) +} + +// Enter returns a formatter for child that uses the same output package and +// imported package name lookup. +func (l LinkedGoType) Enter(child *GoTypePlan) LinkedGoType { + if child == nil { + panic("enter nil retained Go type plan") + } + return LinkedGoType{plan: child, outputPath: l.outputPath, qualifier: l.qualifier} +} + +// Imports returns every package used by this type and its children except the +// output package itself. +func (l LinkedGoType) Imports() []GoTypeImport { + preferences := l.plan.ImportPreferences() + seen := make(map[string]struct{}) + imports := make([]GoTypeImport, 0, len(preferences)) + for _, preference := range preferences { + if preference.Path == l.outputPath { + continue + } + if _, exists := seen[preference.Path]; exists { + continue + } + seen[preference.Path] = struct{}{} + imports = append(imports, GoTypeImport{ + Name: l.qualify(preference.Path), + Path: preference.Path, + }) + } + return imports +} + +// plan copies one attribute and recursively plans anonymous child types. Named +// types stop at their generated declaration. +func (p goTypePlanner) plan(attribute *expr.AttributeExpr, owner, fieldName string, parent *expr.AttributeExpr, definitionPointer bool) (*GoTypePlan, error) { + layoutAttribute := attribute + for { + if _, named := layoutAttribute.Type.(expr.UserType); named { + break + } + composite, ok := layoutAttribute.Type.(expr.CompositeExpr) + if !ok { + break + } + layoutAttribute = composite.Attribute() + } + plan := &GoTypePlan{ + owner: owner, + policy: p.policy, + occurrence: attribute, + fieldNameUpper: GoifyAtt(attribute, fieldName, true), + fieldNameLower: GoifyAtt(attribute, fieldName, false), + description: attribute.Description, + tag: AttributeTagsWithName(parent, fieldName, attribute), + definitionPointer: definitionPointer, + } + if attribute.Description != "" { + plan.comment = Comment(attribute.Description) + } + if parent != nil { + field := expr.AsObject(parent.Type).Attribute(fieldName) + switch { + case expr.IsUnion(field.Type): + plan.fieldPointer = p.policy.UnionPointer && (!parent.IsRequired(fieldName) || p.policy.Pointer) + case !p.policy.SumType: + plan.fieldPointer = expr.IsPrimitive(field.Type) && + (p.policy.Pointer || parent.IsPrimitivePointer(fieldName, p.policy.UseDefault)) + default: + plan.fieldPointer = goFieldIsPointer(parent, fieldName, p.policy.Pointer, p.policy.UseDefault) + } + } + + dataType := layoutAttribute.Type + _, rawObject := dataType.(*expr.Object) + plan.referencePointer = !rawObject && (expr.IsObject(dataType) || expr.IsUnion(dataType)) + switch actual := dataType.(type) { + case expr.Primitive: + plan.kind = GoPrimitive + plan.primitive = GoNativeTypeName(actual) + if custom, importSpec := GetMetaType(layoutAttribute); custom != "" { + plan.primitive = custom + if importSpec != nil { + plan.directImport = GoTypeImport{Name: importSpec.Name, Path: importSpec.Path} + plan.hasDirectImport = true + plan.customQualifier = customTypeQualifier(custom, importSpec.Name) + } + } + case *expr.Array: + plan.kind = GoArray + elementPointer := expr.IsObject(actual.ElemType.Type) || + arrayElementIsPointer(actual, p.policy.ArrayElementPointer) + element, err := p.plan(actual.ElemType, owner, "", nil, elementPointer) + if err != nil { + return nil, err + } + plan.element = element + case *expr.Map: + plan.kind = GoMap + key, err := p.plan(actual.KeyType, owner, "", nil, expr.IsObject(actual.KeyType.Type)) + if err != nil { + return nil, err + } + element, err := p.plan(actual.ElemType, owner, "", nil, expr.IsObject(actual.ElemType.Type)) + if err != nil { + return nil, err + } + plan.key = key + plan.element = element + case *expr.Object: + plan.kind = GoStruct + plan.fields = make([]*GoTypePlan, len(*actual)) + for index, field := range *actual { + child, err := p.plan(field.Attribute, owner, field.Name, layoutAttribute, false) + if err != nil { + return nil, err + } + plan.fields[index] = child + } + case expr.UserType: + switch actual { + case expr.Empty: + plan.kind = GoEmpty + case expr.ErrorResult: + plan.kind = GoServiceError + goaImport := GoaImport("") + plan.directImport = GoTypeImport{Name: goaImport.Name, Path: goaImport.Path} + plan.hasDirectImport = true + default: + plan.kind = GoNamed + binding, err := p.binding(layoutAttribute, owner, GoNamed) + if err != nil { + return nil, err + } + plan.owner = binding.Owner + plan.typeDeclaration = binding.Type + } + case *expr.Union: + plan.kind = GoUnion + binding, err := p.binding(layoutAttribute, owner, GoUnion) + if err != nil { + return nil, err + } + plan.owner = binding.Owner + plan.unionDeclaration = binding.Union + plan.branches = make([]*GoTypePlan, len(actual.Values)) + for index, branch := range actual.Values { + child, err := p.plan(branch.Attribute, binding.Owner, branch.Name, nil, false) + if err != nil { + return nil, err + } + plan.branches[index] = child + } + default: + return nil, fmt.Errorf("plan Go type: unsupported data type %T", actual) + } + return plan, nil +} + +// The planner asks the caller for the generated declaration and checks that its +// package path and type match the request. +func (p goTypePlanner) binding(attribute *expr.AttributeExpr, inheritedOwner string, kind GoTypeKind) (GoTypeBinding, error) { + if p.bind == nil { + return GoTypeBinding{}, fmt.Errorf("plan Go %s: declaration binder must not be nil", kind) + } + binding, err := p.bind(GoTypeBindingRequest{ + Attribute: attribute, + InheritedOwner: inheritedOwner, + Kind: kind, + }) + if err != nil { + return GoTypeBinding{}, fmt.Errorf("plan Go %s %q: %w", kind, attribute.Type.Name(), err) + } + if binding.Owner == "" { + return GoTypeBinding{}, fmt.Errorf("plan Go %s %q: binding owner must not be empty", kind, attribute.Type.Name()) + } + switch kind { + case GoNamed: + if binding.Type == nil || binding.Union != nil { + return GoTypeBinding{}, fmt.Errorf("plan Go named type %q: binding requires only a type declaration", attribute.Type.Name()) + } + if declarationOwner := binding.Type.PackagePath(); declarationOwner != binding.Owner { + return GoTypeBinding{}, fmt.Errorf( + "plan Go named type %q: binding owner %q does not match declaration owner %q", + attribute.Type.Name(), binding.Owner, declarationOwner, + ) + } + case GoUnion: + if binding.Union == nil || binding.Type != nil { + return GoTypeBinding{}, fmt.Errorf("plan Go union %q: binding requires only a union declaration", attribute.Type.Name()) + } + if declarationOwner := binding.Union.PackagePath(); declarationOwner != binding.Owner { + return GoTypeBinding{}, fmt.Errorf( + "plan Go union %q: binding owner %q does not match declaration owner %q", + attribute.Type.Name(), binding.Owner, declarationOwner, + ) + } + } + return binding, nil +} + +// walk visits this plan and then its children in their stored order. +func (p *GoTypePlan) walk(visit func(*GoTypePlan)) { + visit(p) + if p.key != nil { + p.key.walk(visit) + } + if p.element != nil { + p.element.walk(visit) + } + for _, field := range p.fields { + field.walk(visit) + } + for _, branch := range p.branches { + branch.walk(visit) + } +} + +// walkImports visits the types whose packages must be imported by the current +// file. It stops at a union because the file that declares the union imports +// the packages used by its branches. +func (p *GoTypePlan) walkImports(visit func(*GoTypePlan)) { + visit(p) + if p.kind == GoUnion { + return + } + if p.key != nil { + p.key.walkImports(visit) + } + if p.element != nil { + p.element.walkImports(visit) + } + for _, field := range p.fields { + field.walkImports(visit) + } +} + +// customTypeQualifier returns the package name written in a custom Go type. It +// uses alias when provided; otherwise it reads the name before the first dot. +func customTypeQualifier(typeName, alias string) string { + if alias != "" { + return alias + } + dot := strings.IndexByte(typeName, '.') + if dot < 0 { + return "" + } + start := dot + for start > 0 { + char, size := utf8.DecodeLastRuneInString(typeName[:start]) + if !goIdentifierRune(char) { + break + } + start -= size + } + return typeName[start:dot] +} + +// goIdentifierRune reports whether char may occur in a Go identifier. +func goIdentifierRune(char rune) bool { + return char == '_' || unicode.IsLetter(char) || unicode.IsDigit(char) +} + +// qualify returns the package name written before types from importPath. It +// panics when no usable name was planned. +func (l LinkedGoType) qualify(importPath string) string { + if l.qualifier == nil { + panic(fmt.Sprintf("format retained Go type import %q without qualifier lookup", importPath)) + } + qualifier := l.qualifier(importPath) + if qualifier == "" { + panic(fmt.Sprintf("format retained Go type import %q with empty qualifier", importPath)) + } + return qualifier +} + +// qualifiedDeclaration adds the declaring package name before a generated type +// when that type is outside the output package. +func (l LinkedGoType) qualifiedDeclaration(declaration *NameDeclaration) string { + name := declaration.Name() + if l.plan.owner == l.outputPath { + return name + } + return l.qualify(l.plan.owner) + "." + name +} diff --git a/codegen/go_type_plan_test.go b/codegen/go_type_plan_test.go new file mode 100644 index 0000000000..4588fd9cfe --- /dev/null +++ b/codegen/go_type_plan_test.go @@ -0,0 +1,575 @@ +// This file verifies that Go type planning retains every expression-derived +// layout decision before generated package names freeze. +package codegen + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestGoTypePlanRetainsNestedOwners verifies that a binding changes the owner +// inherited by declarations nested beneath the bound occurrence. +func TestGoTypePlanRetainsNestedOwners(t *testing.T) { + const ( + rootOwner = "generated.local/gen/service" + unionOwner = "generated.local/gen/unions" + ) + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + branch := goTypeTestUserType("ChoiceText", expr.String) + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: branch}}, + }, + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "choice", Attribute: &expr.AttributeExpr{Type: union}}, + }} + unionDeclaration := declareGoTypeTestUnion(t, generation, unionOwner, union) + branchDeclaration := declareGoTypeTestUserType(t, generation, unionOwner, branch) + binder := func(request GoTypeBindingRequest) (GoTypeBinding, error) { + switch request.Attribute.Type { + case union: + require.Equal(t, rootOwner, request.InheritedOwner) + return GoTypeBinding{Owner: unionOwner, Union: unionDeclaration}, nil + case branch: + require.Equal(t, unionOwner, request.InheritedOwner) + return GoTypeBinding{Owner: request.InheritedOwner, Type: branchDeclaration}, nil + default: + return GoTypeBinding{}, fmt.Errorf("unexpected binding for %T", request.Attribute.Type) + } + } + + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: rootOwner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: binder, + }) + require.NoError(t, err) + choice := plan.Fields()[0] + require.Equal(t, GoUnion, choice.Kind()) + require.Equal(t, unionOwner, choice.Owner()) + require.Same(t, unionDeclaration, choice.UnionDeclaration()) + require.Len(t, choice.Branches(), 1) + require.Equal(t, unionOwner, choice.Branches()[0].Owner()) + require.Same(t, branchDeclaration, choice.Branches()[0].TypeDeclaration()) + require.Equal(t, []GoTypeImport{{Path: unionOwner}}, plan.ImportPreferences()) + + require.NoError(t, generation.Freeze()) + formatter := plan.Link(rootOwner, goTypeTestQualifier) + require.Equal(t, "struct {\n\tChoice unions.Choice\n}", formatter.Def()) + require.Equal(t, "unions", formatter.Enter(choice).Package()) +} + +// TestGoTypePlanUnionReferenceOwnsOnlyItsDeclarationImport verifies that a +// file referring to a named union does not import packages used only by the +// separate file that defines the union branches. +func TestGoTypePlanUnionReferenceOwnsOnlyItsDeclarationImport(t *testing.T) { + const ( + outputOwner = "generated.local/gen/service" + unionOwner = "generated.local/gen/unions" + branchOwner = "example.com/branch" + ) + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "value", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"branch.Value", branchOwner, "branch"}, + }, + }}, + }, + } + declaration := declareGoTypeTestUnion(t, generation, unionOwner, union) + plan, err := PlanGoType(&expr.AttributeExpr{Type: union}, GoTypePlanOptions{ + Owner: outputOwner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + union: {Owner: unionOwner, Union: declaration}, + }), + }) + require.NoError(t, err) + require.Equal(t, []GoTypeImport{{Path: unionOwner}}, plan.ImportPreferences()) + + require.NoError(t, generation.Freeze()) + linked := plan.Link(outputOwner, func(importPath string) string { + require.Equal(t, unionOwner, importPath) + return "unions" + }) + require.Equal(t, "unions.Choice", linked.Name()) + require.Equal(t, []GoTypeImport{{Name: "unions", Path: unionOwner}}, linked.Imports()) +} + +// TestGoTypePlanRetainsFieldMetadata verifies field names, comments, tags, and +// custom primitive import identity are copied during planning. +func TestGoTypePlanRetainsFieldMetadata(t *testing.T) { + field := &expr.AttributeExpr{ + Type: expr.String, + Description: "stored payload bytes", + Meta: expr.MetaExpr{ + "struct:field:name": {"PayloadID"}, + "struct:field:type": {"json.RawMessage", "encoding/json", "json"}, + "struct:tag:json:name": {"payload_id"}, + "struct:tag:xml": {"payload"}, + }, + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "payload", Attribute: field}, + }} + + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plannedField := plan.Fields()[0] + require.Equal(t, "PayloadID", plannedField.FieldName(true)) + require.Equal(t, "payloadID", plannedField.FieldName(false)) + require.Equal(t, "stored payload bytes", plannedField.Description()) + require.Equal(t, " `json:\"payload_id,omitempty\" xml:\"payload\"`", plannedField.Tag()) + goImport, ok := plannedField.Import() + require.True(t, ok) + require.Equal(t, GoTypeImport{Name: "json", Path: "encoding/json"}, goImport) + require.Equal(t, []GoTypeImport{{Name: "json", Path: "encoding/json"}}, plan.ImportPreferences()) + + formatter := plan.Link("generated.local/gen/service", func(importPath string) string { + require.Equal(t, "encoding/json", importPath) + return "json2" + }) + require.Equal(t, "PayloadID", formatter.Enter(plannedField).Field(true)) + require.Equal(t, "struct {\n\t// stored payload bytes\n\tPayloadID *json2.RawMessage `json:\"payload_id,omitempty\" xml:\"payload\"`\n}", formatter.Def()) +} + +// TestGoTypePlanRebindsCustomTypeQualifier verifies that linking changes only +// the imported package identifier and preserves the complete authored Go type. +func TestGoTypePlanRebindsCustomTypeQualifier(t *testing.T) { + const importPath = "example.com/wire" + tests := []struct { + name string + custom string + want string + }{ + {name: "pointer", custom: "*wire.Value", want: "*wire2.Value"}, + {name: "slice", custom: "[]wire.Value", want: "[]wire2.Value"}, + {name: "nested pointer slice", custom: "[]*wire.Value", want: "[]*wire2.Value"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {test.custom, importPath, "wire"}, + }, + } + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + + linked := plan.Link("generated.local/gen/service", func(path string) string { + require.Equal(t, importPath, path) + return "wire2" + }) + require.Equal(t, test.want, linked.Name()) + }) + } +} + +// TestGoTypePlanRetainsServiceErrorImport verifies that the built-in service +// error type uses the frozen alias selected for Goa's runtime package. +func TestGoTypePlanRetainsServiceErrorImport(t *testing.T) { + const ( + owner = "generated.local/gen/service" + goaPath = "goa.design/goa/v3/pkg" + ) + plan, err := PlanGoType(&expr.AttributeExpr{Type: expr.ErrorResult}, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + require.Equal(t, []GoTypeImport{{Name: "goa", Path: goaPath}}, plan.ImportPreferences()) + + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage(owner) + require.NoError(t, err) + require.NoError(t, pkg.RequireImport(NewImport("goa", "example.com/fixed/goa"))) + for _, preference := range plan.ImportPreferences() { + require.NoError(t, pkg.DeclareImport(NewImport(preference.Name, preference.Path))) + } + require.NoError(t, generation.Freeze()) + require.Equal(t, "goa2", pkg.ImportName(goaPath)) + + linked := plan.Link(owner, pkg.ImportName) + require.Equal(t, "goa2.ServiceError", linked.Name()) + require.Equal(t, "*goa2.ServiceError", linked.Ref()) + require.Equal(t, []GoTypeImport{{Name: "goa2", Path: goaPath}}, linked.Imports()) +} + +// TestGoTypePlanRetainsPointerAndDefaultPolicy verifies field indirection is a +// planning decision rather than a formatting-time expression query. +func TestGoTypePlanRetainsPointerAndDefaultPolicy(t *testing.T) { + withDefault := &expr.AttributeExpr{Type: expr.String, DefaultValue: "ready"} + attribute := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "required", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "optional", Attribute: &expr.AttributeExpr{Type: expr.Int}}, + {Name: "defaulted", Attribute: withDefault}, + {Name: "bytes", Attribute: &expr.AttributeExpr{Type: expr.Bytes}}, + {Name: "nested", Attribute: &expr.AttributeExpr{Type: &expr.Object{}}}, + }, + Validation: &expr.ValidationExpr{Required: []string{"required"}}, + } + + tests := []struct { + name string + pointer bool + want []bool + def string + }{ + { + name: "Goa service policy", + want: []bool{false, true, false, false, true}, + def: "struct {\n\tRequired string\n\tOptional *int\n\tDefaulted string\n\tBytes []byte\n\tNested *struct {\n}\n}", + }, + { + name: "forced primitive pointers", + pointer: true, + want: []bool{true, true, true, false, true}, + def: "struct {\n\tRequired *string\n\tOptional *int\n\tDefaulted *string\n\tBytes []byte\n\tNested *struct {\n}\n}", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + policy := GoLayoutPolicy{ + Pointer: test.pointer, + IgnoreRequired: true, + UseDefault: true, + UnionPointer: true, + SumType: true, + } + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + require.Equal(t, policy, plan.Policy()) + fields := plan.Fields() + for index, want := range test.want { + require.Equal(t, want, fields[index].IsPointer(), fields[index].FieldName(true)) + } + require.Equal(t, test.def, plan.Link(plan.Owner(), goTypeTestQualifier).Def()) + }) + } +} + +// TestGoTypePlanRetainsRequiredArrayElementPointers verifies that only JSON +// input layouts add pointers to primitive elements that must reject null. +func TestGoTypePlanRetainsRequiredArrayElementPointers(t *testing.T) { + const owner = "generated.local/gen/types" + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + alias := goTypeTestUserType("Alias", expr.String) + bytesAlias := goTypeTestUserType("BytesAlias", expr.Bytes) + binder := goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + alias: { + Owner: owner, + Type: declareGoTypeTestUserType(t, generation, owner, alias), + }, + bytesAlias: { + Owner: owner, + Type: declareGoTypeTestUserType(t, generation, owner, bytesAlias), + }, + }) + require.NoError(t, generation.Freeze()) + + tests := []struct { + name string + array *expr.Array + jsonBody bool + want string + }{ + { + name: "built-in string in JSON input", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}, NonNullableElems: true}, + jsonBody: true, + want: "[]*string", + }, + { + name: "string alias in JSON input", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}, NonNullableElems: true}, + jsonBody: true, + want: "[]*Alias", + }, + { + name: "ordinary string alias array", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}}, + jsonBody: true, + want: "[]Alias", + }, + { + name: "service string alias array", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}, NonNullableElems: true}, + want: "[]Alias", + }, + { + name: "bytes alias already represents null", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: bytesAlias}, NonNullableElems: true}, + jsonBody: true, + want: "[]BytesAlias", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan, err := PlanGoType(&expr.AttributeExpr{Type: test.array}, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{ + UseDefault: true, + SumType: true, + ArrayElementPointer: test.jsonBody, + }, + Bind: binder, + }) + require.NoError(t, err) + require.Equal(t, test.want, plan.Link(owner, goTypeTestQualifier).Def()) + }) + } +} + +// TestGoTypePlanFormatsContainersAndUnions verifies array, map, raw struct, +// named object, and union layouts use their retained child and declaration +// policies after linking. +func TestGoTypePlanFormatsContainersAndUnions(t *testing.T) { + const owner = "generated.local/gen/types" + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + item := goTypeTestUserType("Item", &expr.Object{}) + choice := &expr.Union{TypeName: "Choice"} + itemDeclaration := declareGoTypeTestUserType(t, generation, owner, item) + choiceDeclaration := declareGoTypeTestUnion(t, generation, owner, choice) + binder := goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + item: {Owner: owner, Type: itemDeclaration}, + choice: {Owner: owner, Union: choiceDeclaration}, + }) + tests := []struct { + name string + att *expr.AttributeExpr + kind GoTypeKind + wantName string + wantDef string + wantRef string + }{ + { + name: "array of named objects", + att: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: item}}}, + kind: GoArray, + wantName: "[]*Item", + wantDef: "[]*Item", + wantRef: "[]*Item", + }, + { + name: "map of unions", + att: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: choice}, + }}, + kind: GoMap, + wantName: "map[string]*Choice", + wantDef: "map[string]Choice", + wantRef: "map[string]*Choice", + }, + { + name: "array of raw structs", + att: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: &expr.Object{}}}}, + kind: GoArray, + wantName: "[]struct {\n}", + wantDef: "[]*struct {\n}", + wantRef: "[]struct {\n}", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan, err := PlanGoType(test.att, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: binder, + }) + require.NoError(t, err) + require.Equal(t, test.kind, plan.Kind()) + require.NoError(t, generation.Freeze()) + formatter := plan.Link(owner, goTypeTestQualifier) + require.Equal(t, test.wantName, formatter.Name()) + require.Equal(t, test.wantDef, formatter.Def()) + require.Equal(t, test.wantRef, formatter.Ref()) + }) + } +} + +// TestGoTypePlanIgnoresExpressionMutationAfterPlanning verifies formatting +// never revisits type, metadata, descriptions, tags, or required/default state. +func TestGoTypePlanIgnoresExpressionMutationAfterPlanning(t *testing.T) { + const owner = "generated.local/gen/service" + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + record := goTypeTestUserType("Record", &expr.Object{}) + declaration := declareGoTypeTestUserType(t, generation, owner, record) + field := &expr.AttributeExpr{ + Type: record, + Description: "the original record", + Meta: expr.MetaExpr{ + "struct:field:name": {"Original"}, + "struct:tag:json:name": {"original"}, + }, + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "record", Attribute: field}, + }} + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + record: {Owner: owner, Type: declaration}, + }), + }) + require.NoError(t, err) + + field.Type = expr.Int + field.Description = "mutated" + field.DefaultValue = 42 + field.Meta = expr.MetaExpr{ + "struct:field:name": {"Mutated"}, + "struct:field:type": {"time.Time", "time", "time"}, + "struct:tag:json:name": {"mutated"}, + } + attribute.Type = expr.String + + require.NoError(t, generation.Freeze()) + formatter := plan.Link(owner, goTypeTestQualifier) + require.Equal(t, "struct {\n\t// the original record\n\tOriginal *Record `json:\"original,omitempty\"`\n}", formatter.Def()) + require.Equal(t, "Original", formatter.Enter(plan.Fields()[0]).Field(true)) +} + +// TestGoTypePlanSeparatesImportPreferencesFromLinkedImports verifies planning +// retains every authored alias request while linked files import each path once. +func TestGoTypePlanSeparatesImportPreferencesFromLinkedImports(t *testing.T) { + const importPath = "example.com/shared" + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:field:type": {"alpha.Value", importPath, "alpha"}}, + }}, + {Name: "second", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:field:type": {"beta.Value", importPath, "beta"}}, + }}, + }} + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + require.Equal(t, []GoTypeImport{ + {Name: "alpha", Path: importPath}, + {Name: "beta", Path: importPath}, + }, plan.ImportPreferences()) + + linked := plan.Link("generated.local/gen/service", func(path string) string { + require.Equal(t, importPath, path) + return "shared2" + }) + require.Equal(t, []GoTypeImport{{Name: "shared2", Path: importPath}}, linked.Imports()) + require.Equal(t, "struct {\n\tFirst *shared2.Value\n\tSecond *shared2.Value\n}", linked.Def()) +} + +// TestGoTypePlanEquivalence compares symbolic layouts without relying on the +// expression pointers from which independently retained copies were planned. +func TestGoTypePlanEquivalence(t *testing.T) { + firstAttribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Description: "the retained value", + Meta: expr.MetaExpr{ + "struct:field:name": {"ValueID"}, + "struct:tag:json:name": {"value_id"}, + }, + }}, + }} + secondAttribute := expr.DupAtt(firstAttribute) + options := GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + } + first, err := PlanGoType(firstAttribute, options) + require.NoError(t, err) + second, err := PlanGoType(secondAttribute, options) + require.NoError(t, err) + require.True(t, first.Equivalent(second)) + require.True(t, second.Equivalent(first)) + + secondField := (*expr.AsObject(secondAttribute.Type))[0].Attribute + secondField.Meta["struct:field:name"] = []string{"OtherID"} + different, err := PlanGoType(secondAttribute, options) + require.NoError(t, err) + require.False(t, first.Equivalent(different)) + require.False(t, different.Equivalent(first)) +} + +// goTypeTestUserType constructs one named type without running the DSL. +func goTypeTestUserType(name string, dataType expr.DataType) expr.UserType { + return &expr.UserTypeExpr{ + TypeName: name, + AttributeExpr: &expr.AttributeExpr{Type: dataType}, + } +} + +// declareGoTypeTestUserType adds one exact user type to a generated package. +func declareGoTypeTestUserType(t *testing.T, generation *Generation, owner string, userType expr.UserType) *TypeDeclaration { + t.Helper() + generatedPackage, err := generation.ClaimPackage(owner) + require.NoError(t, err) + declaration, err := generatedPackage.DeclareUserType(userType) + require.NoError(t, err) + return declaration +} + +// declareGoTypeTestUnion adds one exact union to a generated package. +func declareGoTypeTestUnion(t *testing.T, generation *Generation, owner string, union *expr.Union) *UnionDeclaration { + t.Helper() + generatedPackage, err := generation.ClaimPackage(owner) + require.NoError(t, err) + declaration, err := generatedPackage.DeclareUnion(union) + require.NoError(t, err) + return declaration +} + +// goTypeTestBinder resolves exact test data types to predeclared package records. +func goTypeTestBinder(bindings map[expr.DataType]GoTypeBinding) GoTypeBinder { + return func(request GoTypeBindingRequest) (GoTypeBinding, error) { + binding, ok := bindings[request.Attribute.Type] + if !ok { + return GoTypeBinding{}, fmt.Errorf("no binding for %T %q", request.Attribute.Type, request.Attribute.Type.Name()) + } + return binding, nil + } +} + +// goTypeTestQualifier supplies stable aliases for focused type-plan tests. +func goTypeTestQualifier(importPath string) string { + switch importPath { + case "generated.local/gen/unions": + return "unions" + case "generated.local/gen/types": + return "types" + default: + return "" + } +} diff --git a/codegen/header.go b/codegen/header.go index db93d1c383..95dce37b79 100644 --- a/codegen/header.go +++ b/codegen/header.go @@ -2,12 +2,14 @@ package codegen import ( "encoding/json" + "fmt" "path/filepath" goa "goa.design/goa/v3/pkg" ) -// Header returns a Go source file header section template. +// Header returns a Go source file header section template. It panics when the +// imports give one package path different explicit package names. func Header(title, pack string, imports []*ImportSpec) *SectionTemplate { return &SectionTemplate{ Name: "source-header", @@ -15,7 +17,7 @@ func Header(title, pack string, imports []*ImportSpec) *SectionTemplate { Data: map[string]any{ "Title": title, "Pkg": pack, - "Imports": imports, + "Imports": appendImports(nil, imports...), }, } } @@ -34,8 +36,8 @@ func VersionFile() *File { } } -// AddImport adds imports to a section template that was generated with -// Header. +// AddImport adds imports to a section template that was generated with Header. +// It panics when one package path is given different explicit package names. func AddImport(section *SectionTemplate, imprts ...*ImportSpec) { if len(imprts) == 0 { return @@ -45,16 +47,42 @@ func AddImport(section *SectionTemplate, imprts ...*ImportSpec) { if imports, ok := data["Imports"]; ok { specs = imports.([]*ImportSpec) } - seen := make(map[ImportSpec]struct{}, len(specs)+len(imprts)) - for _, spec := range specs { - seen[*spec] = struct{}{} - } - for _, spec := range imprts { - if _, ok := seen[*spec]; ok { - continue + data["Imports"] = appendImports(specs, imprts...) +} + +// appendImports keeps one import for each package path. An explicit package +// name replaces an unspecified name. Different explicit names are a generator +// error because one Go file cannot use both names for the same package. +func appendImports(existing []*ImportSpec, additions ...*ImportSpec) []*ImportSpec { + positions := make(map[string]int, len(existing)+len(additions)) + result := make([]*ImportSpec, 0, len(existing)+len(additions)) + appendImport := func(spec *ImportSpec) { + position, ok := positions[spec.Path] + if !ok { + positions[spec.Path] = len(result) + result = append(result, spec) + return } - seen[*spec] = struct{}{} - specs = append(specs, spec) + current := result[position] + switch { + case current.Name == spec.Name, spec.Name == "": + return + case current.Name == "": + result[position] = spec + default: + panic(fmt.Sprintf( + "import path %q uses package names %q and %q", + spec.Path, + current.Name, + spec.Name, + )) + } + } + for _, spec := range existing { + appendImport(spec) + } + for _, spec := range additions { + appendImport(spec) } - data["Imports"] = specs + return result } diff --git a/codegen/import.go b/codegen/import.go index 70e23e5cb9..a4cd3495a8 100644 --- a/codegen/import.go +++ b/codegen/import.go @@ -1,3 +1,5 @@ +// This file models generated Go imports and derives type imports from explicit +// Goa metadata without assigning them to unrelated generated files. package codegen import ( @@ -134,22 +136,23 @@ func GetMetaTypeImports(att *expr.AttributeExpr) []*ImportSpec { } // safelyGetMetaTypeImports parses attributes while keeping track of previous usertypes to avoid infinite recursion -func safelyGetMetaTypeImports(att *expr.AttributeExpr, seen map[string]struct{}) []*ImportSpec { +func safelyGetMetaTypeImports(att *expr.AttributeExpr, seen map[expr.UserType]struct{}) []*ImportSpec { if att == nil { return nil } if seen == nil { - seen = make(map[string]struct{}) + seen = make(map[expr.UserType]struct{}) } uniqueImports := make(map[ImportSpec]struct{}) imports := make([]*ImportSpec, 0) switch t := att.Type.(type) { case expr.UserType: - if _, wasSeen := seen[t.ID()]; wasSeen { + origin := t.Origin() + if _, wasSeen := seen[origin]; wasSeen { return imports } - seen[t.ID()] = struct{}{} + seen[origin] = struct{}{} for _, im := range safelyGetMetaTypeImports(t.Attribute(), seen) { if im != nil { uniqueImports[*im] = struct{}{} @@ -192,12 +195,3 @@ func safelyGetMetaTypeImports(att *expr.AttributeExpr, seen map[string]struct{}) } return imports } - -// AddServiceMetaTypeImports adds meta type imports for each method of the service expr -func AddServiceMetaTypeImports(header *SectionTemplate, svc *expr.ServiceExpr) { - for _, m := range svc.Methods { - AddImport(header, GetMetaTypeImports(m.Payload)...) - AddImport(header, GetMetaTypeImports(m.StreamingPayload)...) - AddImport(header, GetMetaTypeImports(m.Result)...) - } -} diff --git a/codegen/import_aliases.go b/codegen/import_aliases.go new file mode 100644 index 0000000000..c8f871daa0 --- /dev/null +++ b/codegen/import_aliases.go @@ -0,0 +1,220 @@ +// This file chooses the Go name written before each imported type or function. +// Generators submit complete import paths before source is written. After +// Generation.Freeze chooses each name, every file in the output package reads +// the same result. +package codegen + +import ( + "fmt" + "path" + "sort" + + "goa.design/goa/v3/eval" +) + +type ( + // importPriority states why a generator requested an import name. A smaller + // value wins when the same import path has several requested names. + importPriority uint8 + + // importAliasPlan records every requested Go name for each complete import + // path before Generation.Freeze chooses the result. + importAliasPlan struct { + candidates map[string]*importAliasCandidate + } + + // importAliasCandidate groups requested Go names by their reason so the + // strongest requirement for one complete import path wins. + importAliasCandidate struct { + spellings [importPriorityCount]map[string]bool + } + + // importAliasBinding records the Go name written before identifiers from one + // imported package and whether the import line must include that name. + importAliasBinding struct { + name string + explicit bool + } +) + +const ( + fixedImportPriority importPriority = iota + generatedImportPriority + metadataImportPriority + importPriorityCount +) + +// RequireImport records an import name that generated source already refers +// to. It returns an error when the same path is required with a different name. +func (p *GeneratedPackage) RequireImport(spec *ImportSpec) error { + return p.declareImport(spec, fixedImportPriority) +} + +// ReserveGeneratedImport requests a Go name for a generated package. A name +// required by source that is already fixed takes priority, so this request may +// receive a number at the end. +func (p *GeneratedPackage) ReserveGeneratedImport(spec *ImportSpec) error { + return p.declareImport(spec, generatedImportPriority) +} + +// DeclareImport requests the Go name supplied by design metadata. Repeated +// requests for the same complete path are combined before Generation.Freeze. +func (p *GeneratedPackage) DeclareImport(spec *ImportSpec) error { + return p.declareImport(spec, metadataImportPriority) +} + +// Import returns the import line data chosen for importPath. It panics before +// Generation.Freeze or when no generator submitted that path. +func (p *GeneratedPackage) Import(importPath string) *ImportSpec { + binding := p.importBinding(importPath) + return &ImportSpec{Name: explicitImportName(importPath, binding), Path: importPath} +} + +// ImportName returns the Go name written before identifiers imported from +// importPath. It panics before Generation.Freeze or when no generator submitted +// that path. +func (p *GeneratedPackage) ImportName(importPath string) string { + return p.importBinding(importPath).name +} + +// HasRoot reports whether root is one of the exact evaluated roots registered +// when the generation was constructed. +func (g *Generation) HasRoot(root eval.Root) bool { + for _, registered := range g.roots { + if registered == root { + return true + } + } + return false +} + +// declareImport records one requested Go name for an import path. +func (p *GeneratedPackage) declareImport(spec *ImportSpec, priority importPriority) error { + if p.frozen { + return fmt.Errorf("generated package %q is frozen", p.path) + } + importPath, preferred := spec.Path, spec.Name + if importPath == "" { + return nil + } + if preferred == "" { + preferred = path.Base(importPath) + } + candidate, ok := p.importPlan.candidates[importPath] + if !ok { + candidate = &importAliasCandidate{} + p.importPlan.candidates[importPath] = candidate + } + spellings := candidate.spellings[priority] + if spellings == nil { + spellings = make(map[string]bool) + candidate.spellings[priority] = spellings + } + if priority == fixedImportPriority && len(spellings) > 0 { + required, _ := firstImportSpelling(spellings) + if required != preferred { + return fmt.Errorf( + "fixed import path %q requires qualifier %q, not %q", + importPath, + required, + preferred, + ) + } + } + spellings[preferred] = spellings[preferred] || spec.Name != "" + return nil +} + +// freezeImports rejects conflicting required names, then chooses one unused Go +// name for every import path. No import name changes afterward. +func (p *GeneratedPackage) freezeImports() error { + paths := make([]string, 0, len(p.importPlan.candidates)) + for importPath := range p.importPlan.candidates { + paths = append(paths, importPath) + } + sort.Slice(paths, func(i, j int) bool { + left := p.importPlan.candidates[paths[i]].priority() + right := p.importPlan.candidates[paths[j]].priority() + if left != right { + return left < right + } + return paths[i] < paths[j] + }) + fixedPaths := make(map[string]string) + for _, importPath := range paths { + candidate := p.importPlan.candidates[importPath] + if candidate.priority() != fixedImportPriority { + continue + } + name, _ := firstImportSpelling(candidate.spellings[fixedImportPriority]) + if existingPath, ok := fixedPaths[name]; ok && existingPath != importPath { + return fmt.Errorf( + "fixed import qualifier %q is required by both %q and %q", + name, + existingPath, + importPath, + ) + } + fixedPaths[name] = importPath + } + scope := NewNameScope() + bindings := make(map[string]importAliasBinding, len(paths)) + for _, importPath := range paths { + candidate := p.importPlan.candidates[importPath] + spellings := candidate.spellings[candidate.priority()] + preferred, explicit := firstImportSpelling(spellings) + name := scope.Unique(preferred) + bindings[importPath] = importAliasBinding{ + name: name, + explicit: explicit || name != path.Base(importPath), + } + } + scope.Freeze() + p.imports = bindings + return nil +} + +// importBinding returns the Go package name chosen for one import path after +// Generation.Freeze chooses all import names. +func (p *GeneratedPackage) importBinding(importPath string) importAliasBinding { + if !p.frozen { + panic(fmt.Sprintf("generated package %q imports requested before freeze", p.path)) + } + binding, ok := p.imports[importPath] + if !ok { + panic(fmt.Sprintf("import path %q has no planned alias", importPath)) + } + return binding +} + +// firstImportSpelling returns the alphabetically first requested name so the +// order in which generators submit requests cannot change generated source. +func firstImportSpelling(spellings map[string]bool) (string, bool) { + names := make([]string, 0, len(spellings)) + for name := range spellings { + names = append(names, name) + } + sort.Strings(names) + name := names[0] + return name, spellings[name] +} + +// priority returns the strongest reason for a requested import name. +func (c *importAliasCandidate) priority() importPriority { + for priority := fixedImportPriority; priority < importPriorityCount; priority++ { + if len(c.spellings[priority]) > 0 { + return priority + } + } + panic("import alias candidate has no spellings") +} + +// explicitImportName returns an empty string when the import path already ends +// in the chosen Go name; otherwise it returns the name written on the import +// line. +func explicitImportName(importPath string, binding importAliasBinding) string { + if binding.explicit || binding.name != path.Base(importPath) { + return binding.name + } + return "" +} diff --git a/codegen/import_aliases_test.go b/codegen/import_aliases_test.go new file mode 100644 index 0000000000..72f4bd8756 --- /dev/null +++ b/codegen/import_aliases_test.go @@ -0,0 +1,145 @@ +// This file verifies that one generation assigns import qualifiers by explicit +// ownership priority rather than declaration order or import-path sorting. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestImportAliasPrioritiesIgnoreRegistrationOrder verifies that static +// template imports keep required qualifiers ahead of generated packages and +// design metadata regardless of planning order. +func TestImportAliasPrioritiesIgnoreRegistrationOrder(t *testing.T) { + freeze := func(reverse bool) map[string]string { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + declare := []func() error{ + func() error { + return pkg.RequireImport(NewImport("goa", "goa.design/goa/v3/pkg")) + }, + func() error { + return pkg.ReserveGeneratedImport(NewImport("goa", "generated.local/gen/goa")) + }, + func() error { + return pkg.DeclareImport(NewImport("goa", "example.com/custom/goa")) + }, + } + if reverse { + declare[0], declare[2] = declare[2], declare[0] + } + for _, register := range declare { + require.NoError(t, register()) + } + require.NoError(t, generation.Freeze()) + return map[string]string{ + "fixed": pkg.ImportName("goa.design/goa/v3/pkg"), + "generated": pkg.ImportName("generated.local/gen/goa"), + "metadata": pkg.ImportName("example.com/custom/goa"), + } + } + + want := map[string]string{ + "fixed": "goa", + "generated": "goa2", + "metadata": "goa3", + } + require.Equal(t, want, freeze(false)) + require.Equal(t, want, freeze(true)) +} + +// TestImportAliasHighestPriorityWinsPerPath verifies that one complete import +// path has one identity and uses its highest-priority requested spelling. +func TestImportAliasHighestPriorityWinsPerPath(t *testing.T) { + freeze := func(reverse bool) string { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + declare := []func() error{ + func() error { + return pkg.RequireImport(NewImport("json", "encoding/json")) + }, + func() error { + return pkg.ReserveGeneratedImport(NewImport("jason", "encoding/json")) + }, + func() error { + return pkg.DeclareImport(NewImport("jsonp", "encoding/json")) + }, + } + if reverse { + declare[0], declare[2] = declare[2], declare[0] + } + for _, register := range declare { + require.NoError(t, register()) + } + require.NoError(t, generation.Freeze()) + return pkg.ImportName("encoding/json") + } + + require.Equal(t, "json", freeze(false)) + require.Equal(t, "json", freeze(true)) +} + +// TestGeneratedImportPreferenceIsOrderIndependent verifies that repeated +// generated-package preferences for one path use deterministic spelling. +func TestGeneratedImportPreferenceIsOrderIndependent(t *testing.T) { + freeze := func(first, second string) string { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + require.NoError(t, pkg.ReserveGeneratedImport(NewImport(first, "generated.local/gen/value"))) + require.NoError(t, pkg.ReserveGeneratedImport(NewImport(second, "generated.local/gen/value"))) + require.NoError(t, generation.Freeze()) + return pkg.ImportName("generated.local/gen/value") + } + + require.Equal(t, freeze("alpha", "zeta"), freeze("zeta", "alpha")) +} + +// TestImportAliasRejectsIncompatibleFixedRequirements verifies that static +// templates cannot request two different mandatory spellings for one path. +func TestImportAliasRejectsIncompatibleFixedRequirements(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + require.NoError(t, pkg.RequireImport(NewImport("json", "encoding/json"))) + require.ErrorContains( + t, + pkg.RequireImport(NewImport("jason", "encoding/json")), + "requires qualifier", + ) +} + +// TestImportAliasRejectsFixedQualifierCollision verifies that two static +// packages cannot both require the same qualifier. +func TestImportAliasRejectsFixedQualifierCollision(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + require.NoError(t, pkg.RequireImport(NewImport("runtime", "example.com/first"))) + require.NoError(t, pkg.RequireImport(NewImport("runtime", "example.com/second"))) + require.ErrorContains(t, generation.Freeze(), "required by both") +} + +// TestImportAliasesAreIndependentAcrossOutputPackages verifies that packages +// which never compile together may use the same natural import name. +func TestImportAliasesAreIndependentAcrossOutputPackages(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + httpPackage, err := generation.ClaimPackage("generated.local/gen/http/cli/calc") + require.NoError(t, err) + grpcPackage, err := generation.ClaimPackage("generated.local/gen/grpc/cli/calc") + require.NoError(t, err) + require.NoError(t, httpPackage.ReserveGeneratedImport(NewImport( + "calcc", + "generated.local/gen/http/calc/client", + ))) + require.NoError(t, grpcPackage.ReserveGeneratedImport(NewImport( + "calcc", + "generated.local/gen/grpc/calc/client", + ))) + require.NoError(t, generation.Freeze()) + require.Equal(t, "calcc", httpPackage.ImportName("generated.local/gen/http/calc/client")) + require.Equal(t, "calcc", grpcPackage.ImportName("generated.local/gen/grpc/calc/client")) +} diff --git a/codegen/import_test.go b/codegen/import_test.go index 3aae8c7273..720ad3801d 100644 --- a/codegen/import_test.go +++ b/codegen/import_test.go @@ -1,3 +1,5 @@ +// This file verifies that import discovery follows complete attribute shapes +// while keeping independent user declarations distinct during cycle checks. package codegen import ( @@ -170,3 +172,42 @@ func TestGetMetaTypeImports(t *testing.T) { }) } } + +// TestGetMetaTypeImportsKeepsIndependentDeclarationsWithOneID verifies +// semantic IDs do not collapse imports from exact in-memory declarations. +func TestGetMetaTypeImportsKeepsIndependentDeclarationsWithOneID(t *testing.T) { + first := &expr.UserTypeExpr{ + TypeName: "First", + UID: "shared-semantic-id", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"First", "example.com/first"}, + }, + }, + } + second := &expr.UserTypeExpr{ + TypeName: "Second", + UID: "shared-semantic-id", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"Second", "example.com/second"}, + }, + }, + } + object := expr.Object{ + &expr.NamedAttributeExpr{Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + &expr.NamedAttributeExpr{Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + } + + imports := GetMetaTypeImports(&expr.AttributeExpr{Type: &object}) + paths := make([]string, len(imports)) + for i, spec := range imports { + paths[i] = spec.Path + } + sort.Strings(paths) + if want := []string{"example.com/first", "example.com/second"}; !reflect.DeepEqual(paths, want) { + t.Errorf("want %+v, got %+v", want, paths) + } +} diff --git a/codegen/internal/pluginregistry/registry.go b/codegen/internal/pluginregistry/registry.go new file mode 100644 index 0000000000..6420c4cb34 --- /dev/null +++ b/codegen/internal/pluginregistry/registry.go @@ -0,0 +1,113 @@ +// Package pluginregistry stores callbacks registered through Goa's released +// plugin API until generation starts. Plugin authors may register callbacks, +// but only the generator may stop registration or read the stored list. +package pluginregistry + +import ( + "fmt" + "slices" + "sync" +) + +type ( + // Position identifies when a plugin runs relative to normal plugins. + Position uint8 + + // Registry stores callbacks until the first generation run copies them. + // Callback types stay paired with the package that defines them. + Registry struct { + mu sync.Mutex + registrations []storedRegistration + sealed bool + } + + // Registration is one plugin definition with its original callback types. + Registration[Prepare, Generate any] struct { + Name string + Command string + Position Position + Prepare Prepare + Generate Generate + } + + // storedRegistration keeps callbacks without importing their defining + // package. Snapshot restores the types supplied by that same package. + storedRegistration struct { + name string + command string + position Position + prepare any + generate any + } +) + +const ( + // First places a plugin before normally ordered plugins. + First Position = iota + // Normal places a plugin between first and last plugins. + Normal + // Last places a plugin after normally ordered plugins. + Last +) + +var defaultRegistry = New() + +// New creates an open plugin registry for Goa or a focused test. +func New() *Registry { + return &Registry{} +} + +// Register records one plugin in the process-wide registry used by Goa. +func Register[Prepare, Generate any](name, command string, position Position, prepare Prepare, generate Generate) { + RegisterIn(defaultRegistry, name, command, position, prepare, generate) +} + +// RegisterIn records one plugin in registry before generation starts. +func RegisterIn[Prepare, Generate any](registry *Registry, name, command string, position Position, prepare Prepare, generate Generate) { + registry.mu.Lock() + defer registry.mu.Unlock() + if registry.sealed { + panic("plugin registry is sealed") + } + registry.registrations = append(registry.registrations, storedRegistration{ + name: name, + command: command, + position: position, + prepare: prepare, + generate: generate, + }) +} + +// Snapshot stops further process-wide registrations and returns a copy of the +// registered plugins with their original callback types. +func Snapshot[Prepare, Generate any]() []Registration[Prepare, Generate] { + return SnapshotFrom[Prepare, Generate](defaultRegistry) +} + +// SnapshotFrom stops further registrations in registry and returns a copy that +// callers may sort without changing the stored order. +func SnapshotFrom[Prepare, Generate any](registry *Registry) []Registration[Prepare, Generate] { + registry.mu.Lock() + defer registry.mu.Unlock() + registry.sealed = true + stored := slices.Clone(registry.registrations) + registrations := make([]Registration[Prepare, Generate], len(stored)) + for index, plugin := range stored { + prepare, ok := plugin.prepare.(Prepare) + if !ok { + panic(fmt.Sprintf("plugin %q has an unexpected prepare callback type", plugin.name)) + } + generate, ok := plugin.generate.(Generate) + if !ok { + panic(fmt.Sprintf("plugin %q has an unexpected generate callback type", plugin.name)) + } + registrations[index] = Registration[Prepare, Generate]{ + Name: plugin.name, + Command: plugin.command, + Position: plugin.position, + Prepare: prepare, + Generate: generate, + } + } + return registrations +} diff --git a/codegen/name_declaration.go b/codegen/name_declaration.go new file mode 100644 index 0000000000..77dd8be0b5 --- /dev/null +++ b/codegen/name_declaration.go @@ -0,0 +1,247 @@ +// This file records package-level Go names before source is written. Names that +// must remain exact are assigned first, then generated names receive numeric +// suffixes when needed. After that, the names cannot change. +package codegen + +import ( + "fmt" + "go/token" + "reflect" + "strings" +) + +type ( + // PackageNameKind states whether a package-level name declares a type, + // function, constant, or variable. All four kinds must have different names + // within one Go package. + PackageNameKind uint8 + + // PackageNameVisibility states whether generated code outside the package can + // use a preferred name. + PackageNameVisibility uint8 + + // PackageNameOrder sorts generated declarations that request the same name. + // Implementations must be named, non-pointer values containing only values + // that cannot change. Values of different concrete types are sorted by their + // package and type names before this method is called. + PackageNameOrder interface { + // ComparePackageName compares two values of the same concrete type. It must + // return a negative value when the receiver comes first, zero when the values + // are equal, and a positive value when the receiver comes last. Reversing the + // arguments must reverse the sign, and comparison must sort consistently. + ComparePackageName(PackageNameOrder) int + } + + // NameDeclaration records one package-level Go name. Name cannot be read + // until Generation.Freeze chooses its final spelling among all declarations + // in the package. + NameDeclaration struct { + kind PackageNameKind + visibility PackageNameVisibility + preferred string + final string + owner *GeneratedPackage + exact bool + order PackageNameOrder + base *NameDeclaration + prefix string + suffix string + hashes []Hasher + frozen bool + } +) + +const ( + // NameType marks a package-level type name. + NameType PackageNameKind = iota + 1 + // NameFunction marks a package-level function name. + NameFunction + // NameConstant marks a package-level constant name. + NameConstant + // NameVariable marks a package-level variable name. + NameVariable +) + +const ( + // ExportedName requests a name that code outside the package can use. + ExportedName PackageNameVisibility = iota + 1 + // UnexportedName requests a name that only code in the package can use. + UnexportedName +) + +// NewExactName records a package-level Go name that must not change. Adding the +// declaration to a generated package fails if name is invalid or already used. +func NewExactName(kind PackageNameKind, name string) *NameDeclaration { + return &NameDeclaration{ + kind: kind, + preferred: name, + exact: true, + } +} + +// NewPreferredName records a generated name that may receive a numeric suffix +// when another declaration requests the same spelling. order decides which +// declaration keeps the unsuffixed name and must be a named, non-pointer value +// containing only values that cannot change. +func NewPreferredName(kind PackageNameKind, preferred string, visibility PackageNameVisibility, order PackageNameOrder) *NameDeclaration { + return &NameDeclaration{ + kind: kind, + visibility: visibility, + preferred: Goify(preferred, visibility == ExportedName), + order: order, + } +} + +// Name returns the final Go name. It panics until Generation.Freeze chooses all +// declaration names because another declaration may still change this one. +func (d *NameDeclaration) Name() string { + if !d.frozen { + panic(fmt.Sprintf("package name %q requested before generation freeze", d.preferredName())) + } + return d.final +} + +// Kind returns whether this name declares a type, function, constant, or +// variable. +func (d *NameDeclaration) Kind() PackageNameKind { + return d.kind +} + +// String returns the declaration kind used in error messages. +func (k PackageNameKind) String() string { + switch k { + case NameType: + return "type" + case NameFunction: + return "function" + case NameConstant: + return "constant" + case NameVariable: + return "variable" + default: + return "unknown" + } +} + +// newDependentName records a name formed by adding prefix and suffix to base's +// final name. +func newDependentName(kind PackageNameKind, base *NameDeclaration, prefix, suffix string, order PackageNameOrder) *NameDeclaration { + if base == nil { + panic("dependent package name requires a base declaration") + } + return &NameDeclaration{ + kind: kind, + order: order, + base: base, + prefix: prefix, + suffix: suffix, + } +} + +// comparePackageNames sorts declarations by their order value rather than the +// order in which generators added them. Two distinct declarations must not +// compare equal. +func comparePackageNames(left, right *NameDeclaration) int { + leftType := reflect.TypeOf(left.order) + rightType := reflect.TypeOf(right.order) + if compared := strings.Compare(leftType.PkgPath(), rightType.PkgPath()); compared != 0 { + return compared + } + if compared := strings.Compare(leftType.Name(), rightType.Name()); compared != 0 { + return compared + } + return left.order.ComparePackageName(right.order) +} + +// validateNameDeclaration checks that declaration has a valid kind, visibility, +// and requested Go name before a generated package records it. +func validateNameDeclaration(declaration *NameDeclaration) error { + if !declaration.kind.valid() { + return fmt.Errorf("invalid package name kind %d", declaration.kind) + } + if declaration.base == nil && !declaration.exact && !declaration.visibility.valid() { + return fmt.Errorf("invalid package name visibility %d", declaration.visibility) + } + if declaration.preferredName() == "" { + return fmt.Errorf("package name must not be empty") + } + if declaration.exact && !token.IsIdentifier(declaration.preferred) { + return fmt.Errorf("package name %q is not a valid Go identifier", declaration.preferred) + } + return nil +} + +// valid reports whether v is one of the supported visibility values. +func (v PackageNameVisibility) valid() bool { + return v == ExportedName || v == UnexportedName +} + +// validatePackageNameOrder checks that order is a named value whose contents +// cannot change after a generated package records it. +func validatePackageNameOrder(order PackageNameOrder) error { + if order == nil { + return fmt.Errorf("package name order must be a stable concrete named value type") + } + typeOf := reflect.TypeOf(order) + if typeOf.Name() == "" || typeOf.PkgPath() == "" || !isStablePackageNameOrderType(typeOf) { + return fmt.Errorf("package name order %T must be a stable concrete named value type", order) + } + return nil +} + +// isStablePackageNameOrderType reports whether typeOf contains only values that +// cannot change after they are copied. +func isStablePackageNameOrderType(typeOf reflect.Type) bool { + switch typeOf.Kind() { + case reflect.Array: + return isStablePackageNameOrderType(typeOf.Elem()) + case reflect.Struct: + for i := range typeOf.NumField() { + if !isStablePackageNameOrderType(typeOf.Field(i).Type) { + return false + } + } + return true + case reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, + reflect.String: + return true + default: + return false + } +} + +// packagePath returns the import path of the package that declares this name. +// It panics if no generated package has recorded the declaration. +func (d *NameDeclaration) packagePath() string { + if d.owner == nil { + panic(fmt.Sprintf("package name %q has no generated package owner", d.preferredName())) + } + return d.owner.path +} + +// preferredName returns the requested name. A dependent name uses base's final +// spelling once available, then adds its prefix and suffix. +func (d *NameDeclaration) preferredName() string { + if d.base == nil { + return d.preferred + } + base := d.base.preferred + if d.base.frozen { + base = d.base.final + } + return d.prefix + base + d.suffix +} + +// valid reports whether k is one of the supported declaration kinds. +func (k PackageNameKind) valid() bool { + switch k { + case NameType, NameFunction, NameConstant, NameVariable: + return true + default: + return false + } +} diff --git a/codegen/normalize.go b/codegen/normalize.go index 0eaa03005e..b17aba6be2 100644 --- a/codegen/normalize.go +++ b/codegen/normalize.go @@ -1,206 +1,86 @@ +// This file wraps unnamed method payload and result objects in user types +// after evaluation. It records each wrapper so later code finds the same +// generated type without trusting a user-provided string. package codegen import ( - "strings" - + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) -// NormalizeRoot applies the only sanctioned design mutation that may happen -// after the DSL has been evaluated and finalized: it wraps the raw object -// payload, result and streaming types of every service method into -// synthesized user types named after the method. Every code generation layer -// (service, transports, OpenAPI, example, CLI and type conversion) relies on -// method payload and result types being named, so the wrapping must happen -// before any generator reads the design. -// -// NormalizeRoot is idempotent: already wrapped methods are left untouched. It -// must run after the prepare plugins so that plugin contributed endpoints are -// normalized too, and before any generator runs. Past this point the design -// expression tree is read-only for all generators; the purity test in -// codegen/generator enforces that contract. -// -// The synthesized type names are resolved against a name scope seeded with -// the exact same registrations the service generator performs when it -// collects the service user types (see codegen/service analyze) so that -// wrapping up front produces the very same type names the service generator -// produced when it owned the wrapping. -func NormalizeRoot(r *expr.RootExpr) { - for _, svc := range r.Services { - normalizeService(svc) - } -} - -// normalizeService wraps the raw object method types of svc into synthesized -// user types. The name scope fed to PeekUnique is seeded by replaying the -// scope side effects of the service generator analysis in the same order: -// reserved identifiers and package name first, then the user types reachable -// from the service errors and from each method payload, streaming payload, -// result, streaming result, projected result types and method errors. -func normalizeService(svc *expr.ServiceExpr) { - scope := NewNameScope() - scope.Unique("Use") // Reserve "Use" for Endpoints struct Use method. - scope.Unique("websocket") // Reserve "websocket" to avoid collision with gorilla/websocket - scope.HashedUnique(svc, strings.ToLower(Goify(svc.Name, false)), "svc") - seen := make(map[string]struct{}) - for _, er := range svc.Errors { - seedTypeNames(er.AttributeExpr, scope, seen) - } - seedMethodAtt := func(att *expr.AttributeExpr) { - if att == nil { - return - } - if ut, ok := att.Type.(expr.UserType); ok { - att = ut.Attribute() - } - seedTypeNames(att, scope, seen) - } - seenProjected := make(map[string]struct{}) - for _, m := range svc.Methods { - seedMethodAtt(m.Payload) - seedMethodAtt(m.StreamingPayload) - seedMethodAtt(m.Result) - if m.HasMixedResults() { - seedMethodAtt(m.StreamingResult) - } - if hasResultTypeExpr(m.Result, make(map[string]struct{})) { - seedProjectedNames(expr.DupAtt(m.Result), m.Result, scope, seenProjected) - } - for _, er := range m.Errors { - seedTypeNames(er.AttributeExpr, scope, seen) - } - } - wrap := func(att *expr.AttributeExpr, name, id string) { - if att == nil { - return - } - if _, ok := att.Type.(*expr.Object); !ok { - return - } - att.Type = &expr.UserTypeExpr{ - AttributeExpr: expr.DupAtt(att), - TypeName: scope.PeekUnique(name), - UID: id, - } - } - for _, m := range svc.Methods { - name := Goify(m.Name, true) - wrap(m.Payload, name+"Payload", svc.Name+"#"+name+"Payload") - wrap(m.StreamingPayload, name+"StreamingPayload", svc.Name+"#"+name+"StreamingPayload") - wrap(m.Result, name+"Result", svc.Name+"#"+name+"Result") - if m.HasMixedResults() { - wrap(m.StreamingResult, name+"StreamingResult", svc.Name+"#"+name+"StreamingResult") +// normalizeRoots wraps raw method objects in every Goa design root and returns +// the exact compiler-created declarations with their closed method roles. +func normalizeRoots(roots []eval.Root) map[expr.UserType]MethodTypeIdentity { + normalized := make(map[expr.UserType]MethodTypeIdentity) + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + normalizeRoot(design, normalized) } } + return normalized } -// seedTypeNames mirrors the name scope side effects of the service generator -// user type collection (collectTypes in codegen/service): every user type -// reachable from at reserves its Go type name, the names referenced by its -// type definition and its type reference, in the same order. The returned -// strings are discarded, only the scope registrations matter. -func seedTypeNames(at *expr.AttributeExpr, scope *NameScope, seen map[string]struct{}) { - if at == nil || at.Type == expr.Empty { - return +// normalizeRoot records every wrapper created for one design root. +func normalizeRoot(root *expr.RootExpr, normalized map[expr.UserType]MethodTypeIdentity) { + apiName := "" + if root.API != nil { + apiName = root.API.Name } - switch dt := at.Type.(type) { - case expr.UserType: - if _, ok := seen[dt.ID()]; ok { - return - } - scope.GoTypeName(at) - scope.GoTypeDef(dt.Attribute(), false, true) - scope.GoTypeRef(at) - seen[dt.ID()] = struct{}{} - seedTypeNames(dt.Attribute(), scope, seen) - case *expr.Object: - for _, nat := range *dt { - seedTypeNames(nat.Attribute, scope, seen) - } - case *expr.Array: - seedTypeNames(dt.ElemType, scope, seen) - case *expr.Map: - seedTypeNames(dt.KeyType, scope, seen) - seedTypeNames(dt.ElemType, scope, seen) - case *expr.Union: - for _, nat := range dt.Values { - seedTypeNames(nat.Attribute, scope, seen) - } + for _, service := range root.Services { + normalizeService(apiName, service, normalized) } } -// seedProjectedNames mirrors the name scope side effects of the projected -// type collection (collectProjectedTypes and the view conversion builders in -// codegen/service): projected is a detached copy of the method result -// attribute whose user types are renamed with the "View" suffix while -// traversing, and every result type with views reserves the projected and -// original type names in the service scope, children first. -func seedProjectedNames(projected, att *expr.AttributeExpr, scope *NameScope, seen map[string]struct{}) { - switch pt := projected.Type.(type) { - case expr.UserType: - dt := att.Type.(expr.UserType) - if _, ok := seen[dt.ID()]; ok { - return - } - seen[dt.ID()] = struct{}{} - pt.Rename(pt.Name() + "View") - seedProjectedNames(pt.Attribute(), dt.Attribute(), scope, seen) - if rt, ok := pt.(*expr.ResultTypeExpr); ok && len(rt.Views) > 0 { - if parr := expr.AsArray(pt); parr != nil { - scope.GoTypeName(parr.ElemType) - } - scope.GoTypeName(projected) - scope.GoTypeName(att) - } - case *expr.Array: - seedProjectedNames(pt.ElemType, att.Type.(*expr.Array).ElemType, scope, seen) - case *expr.Map: - dt := att.Type.(*expr.Map) - seedProjectedNames(pt.KeyType, dt.KeyType, scope, seen) - seedProjectedNames(pt.ElemType, dt.ElemType, scope, seen) - case *expr.Object: - dt := att.Type.(*expr.Object) - for _, n := range *pt { - seedProjectedNames(n.Attribute, dt.Attribute(n.Name), scope, seen) - } - case *expr.Union: - dt := att.Type.(*expr.Union) - for i, n := range pt.Values { - seedProjectedNames(n.Attribute, dt.Values[i].Attribute, scope, seen) +// normalizeService wraps each unnamed object payload and result in a generated +// user type without reading or changing generated Go names. +func normalizeService(apiName string, service *expr.ServiceExpr, normalized map[expr.UserType]MethodTypeIdentity) { + for _, method := range service.Methods { + normalizeMethodAttribute(method.Payload, newMethodTypeIdentity( + apiName, + method.Name, + methodPayloadTypeKind, + expr.MethodPayloadExampleIdentity(method), + ), normalized) + normalizeMethodAttribute(method.StreamingPayload, newMethodTypeIdentity( + apiName, + method.Name, + methodStreamingPayloadTypeKind, + expr.MethodStreamingPayloadExampleIdentity(method), + ), normalized) + normalizeMethodAttribute(method.Result, newMethodTypeIdentity( + apiName, + method.Name, + methodResultTypeKind, + expr.MethodResultExampleIdentity(method), + ), normalized) + if method.HasMixedResults() { + normalizeMethodAttribute(method.StreamingResult, newMethodTypeIdentity( + apiName, + method.Name, + methodStreamingResultTypeKind, + expr.MethodStreamingResultExampleIdentity(method), + ), normalized) } } } -// hasResultTypeExpr reports whether att transitively references a result type -// expression. It mirrors hasResultType in codegen/service which decides -// whether the service generator collects projected types for a method result. -func hasResultTypeExpr(att *expr.AttributeExpr, seen map[string]struct{}) bool { - if _, ok := att.Type.(*expr.ResultTypeExpr); ok { - return true +// normalizeMethodAttribute records which generated wrapper was created for an +// unnamed object. Existing named and non-object method types remain unchanged. +func normalizeMethodAttribute(attribute *expr.AttributeExpr, identity MethodTypeIdentity, normalized map[expr.UserType]MethodTypeIdentity) { + if attribute == nil { + return } - switch a := att.Type.(type) { - case expr.UserType: - if _, ok := seen[a.ID()]; ok { - return false - } - seen[a.ID()] = struct{}{} - return hasResultTypeExpr(a.Attribute(), seen) - case *expr.Array: - return hasResultTypeExpr(a.ElemType, seen) - case *expr.Map: - return hasResultTypeExpr(a.KeyType, seen) || hasResultTypeExpr(a.ElemType, seen) - case *expr.Object: - for _, nat := range *a { - if hasResultTypeExpr(nat.Attribute, seen) { - return true - } - } - case *expr.Union: - for _, nat := range a.Values { - if hasResultTypeExpr(nat.Attribute, seen) { - return true - } + if userType, ok := attribute.Type.(expr.UserType); ok { + exampleIdentity, generated := expr.GeneratedUserTypeExampleIdentity(userType) + if generated && exampleIdentity == identity.exampleIdentity { + normalized[userType.Origin()] = identity.bind(userType) } + return + } + if _, ok := attribute.Type.(*expr.Object); !ok { + return } - return false + wrapper := expr.NewGeneratedUserType(identity.Name(), expr.DupAtt(attribute), identity.exampleIdentity) + attribute.Type = wrapper + normalized[wrapper.Origin()] = identity.bind(wrapper) } diff --git a/codegen/plugin.go b/codegen/plugin.go index 57ba30d7a1..9e20fe3e34 100644 --- a/codegen/plugin.go +++ b/codegen/plugin.go @@ -1,126 +1,74 @@ +// This file stores plugins registered through the released Goa v3 API. At the +// start of each generation command, the generator copies the registered +// functions. It calls First plugins before normal plugins and Last plugins +// afterward, orders names within each group, and keeps registration order when +// plugins in one group have the same name. This package stores those functions +// but does not call them. package codegen -import "goa.design/goa/v3/eval" +import ( + "fmt" + + "goa.design/goa/v3/codegen/internal/pluginregistry" + "goa.design/goa/v3/eval" +) type ( - // GenerateFunc makes it possible to modify the files generated by the - // goa code generators and other plugins. A GenerateFunc accepts the Go - // import path of the "gen" package, the design roots as well as the - // currently generated files (produced initially by the goa generators - // and potentially modified by previously run plugins) and returns a new - // set of files. + // GenerateFunc may add, remove, or change the files produced by Goa and by + // plugins that ran earlier. It returns the complete file list for the next + // plugin. GenerateFunc func(genpkg string, roots []eval.Root, files []*File) ([]*File, error) - // PrepareFunc makes it possible to modify the design roots before - // the files being generated by the goa code generators or other plugins. + // PrepareFunc may change evaluated designs before Goa chooses generated Go + // names. A nil PrepareFunc means that the plugin does not prepare designs. PrepareFunc func(genpkg string, roots []eval.Root) error - // plugin is a plugin that has been registered with a given command. - plugin struct { - // PrepareFunc is the plugin preparation function. - PrepareFunc - // GenerateFunc is the plugin generator function. - GenerateFunc - // name is the plugin name. - name string - // cmd is the name of cmd to run. - cmd string - // if first is set the plugin cmd must run before all other plugins. - first bool - // if last is set the plugin cmd must run after all other plugins. - last bool - } + // pluginPosition identifies the three ordering groups supported by the + // released registration API. + pluginPosition = pluginregistry.Position ) -// plugins keeps track of the registered plugins sorted by their first/last bools, -// names, or registration order. -var plugins []*plugin +const ( + pluginFirst = pluginregistry.First + pluginNormal = pluginregistry.Normal + pluginLast = pluginregistry.Last +) -// RegisterPlugin adds the plugin to the list of plugins to be invoked with the -// given command. -func RegisterPlugin(name string, cmd string, pre PrepareFunc, p GenerateFunc) { - np := &plugin{name: name, PrepareFunc: pre, GenerateFunc: p, cmd: cmd} - var inserted bool - for i, plgn := range plugins { - if plgn.last || (!plgn.first && np.name < plgn.name) { - plugins = append(plugins[:i], append([]*plugin{np}, plugins[i:]...)...) - inserted = true - break - } - } - if !inserted { - plugins = append(plugins, np) - } +// RegisterPlugin adds a plugin to the normal alphabetically ordered group. It +// panics for an empty name, an unknown command, a nil generation function, or +// registration after generation has started. Repeated names remain allowed for +// compatibility with released Goa plugins. +func RegisterPlugin(name, command string, prepare PrepareFunc, generate GenerateFunc) { + registerPlugin(name, command, pluginNormal, prepare, generate) } -// RegisterPluginFirst adds the plugin to the beginning of the list of plugins -// to be invoked with the given command. If more than one plugins are registered -// using this, the plugins will be sorted alphabetically by their names. If two -// plugins have same names, then they are sorted by registration order. -func RegisterPluginFirst(name string, cmd string, pre PrepareFunc, p GenerateFunc) { - np := &plugin{name: name, PrepareFunc: pre, GenerateFunc: p, cmd: cmd, first: true} - var inserted bool - for i, plgn := range plugins { - if !plgn.first || np.name < plgn.name { - plugins = append(plugins[:i], append([]*plugin{np}, plugins[i:]...)...) - inserted = true - break - } - } - if !inserted { - plugins = append(plugins, np) - } +// RegisterPluginFirst adds a plugin before normal and last plugins. Plugins in +// this group run by name. Plugins with the same name run in registration order. +func RegisterPluginFirst(name, command string, prepare PrepareFunc, generate GenerateFunc) { + registerPlugin(name, command, pluginFirst, prepare, generate) } -// RegisterPluginLast adds the plugin to the end of the list of plugins -// to be invoked with the given command. If more than one plugins are registered -// using this, the plugins will be sorted alphabetically by their names. If two -// plugins have same names, then they are sorted by registration order. -func RegisterPluginLast(name string, cmd string, pre PrepareFunc, p GenerateFunc) { - np := &plugin{name: name, PrepareFunc: pre, GenerateFunc: p, cmd: cmd, last: true} - var inserted bool - for i := len(plugins) - 1; i >= 0; i-- { - plgn := plugins[i] - if !plgn.last || plgn.name < np.name { - plugins = append(plugins[:i+1], append([]*plugin{np}, plugins[i+1:]...)...) - inserted = true - break - } - } - if !inserted { - plugins = append(plugins, np) - } +// RegisterPluginLast adds a plugin after first and normal plugins. Plugins in +// this group run by name. Plugins with the same name run in registration order. +func RegisterPluginLast(name, command string, prepare PrepareFunc, generate GenerateFunc) { + registerPlugin(name, command, pluginLast, prepare, generate) } -// RunPluginsPrepare executes the plugins prepare functions in the order -// they were registered. -func RunPluginsPrepare(cmd, genpkg string, roots []eval.Root) error { - for _, plugin := range plugins { - if plugin.cmd != cmd { - continue - } - if plugin.PrepareFunc != nil { - err := plugin.PrepareFunc(genpkg, roots) - if err != nil { - return err - } - } - } - return nil +// register validates and records one plugin definition before generation. +func registerPlugin(name, command string, position pluginPosition, prepare PrepareFunc, generate GenerateFunc) { + validatePlugin(name, command, generate) + pluginregistry.Register(name, command, position, prepare, generate) } -// RunPlugins executes the plugins registered with the given command in the order -// they were registered. -func RunPlugins(cmd, genpkg string, roots []eval.Root, genfiles []*File) ([]*File, error) { - for _, plugin := range plugins { - if plugin.cmd != cmd { - continue - } - gs, err := plugin.GenerateFunc(genpkg, roots, genfiles) - if err != nil { - return nil, err - } - genfiles = gs +// validatePlugin rejects definitions that the generator cannot execute. +func validatePlugin(name, command string, generate GenerateFunc) { + if name == "" { + panic("plugin name is empty") + } + if command != "gen" && command != "example" { + panic(fmt.Sprintf("unknown generator command %q", command)) + } + if generate == nil { + panic("plugin generate function is nil") } - return genfiles, nil } diff --git a/codegen/plugin_test.go b/codegen/plugin_test.go index 7cec4e091e..9d79ae3e8a 100644 --- a/codegen/plugin_test.go +++ b/codegen/plugin_test.go @@ -1,110 +1,102 @@ +// This file verifies the released plugin registration calls without running a +// second generation pipeline. The generator package consumes the copied +// registrations tested here. package codegen import ( - "reflect" "testing" -) -func TestRegisterPlugin(t *testing.T) { - var ( - p1 = &plugin{name: "abc"} - p2 = &plugin{name: "def"} + "github.com/stretchr/testify/require" - pf1 = &plugin{name: "abc", first: true} + "goa.design/goa/v3/codegen/internal/pluginregistry" + "goa.design/goa/v3/eval" +) - pl1 = &plugin{name: "abc", last: true} +// TestReleasedPluginRegistrationSignatures verifies that existing plugin +// packages still compile against the four-argument Goa v3 API. +func TestReleasedPluginRegistrationSignatures(t *testing.T) { + var register func(string, string, PrepareFunc, GenerateFunc) - pIns = &plugin{name: "cde"} - ) + register = RegisterPlugin + require.NotNil(t, register) + register = RegisterPluginFirst + require.NotNil(t, register) + register = RegisterPluginLast + require.NotNil(t, register) +} + +// TestPluginRegistryRejectsInvalidRegistration verifies that invalid plugin +// definitions fail when they are registered, before generation can start. +func TestPluginRegistryRejectsInvalidRegistration(t *testing.T) { tests := []struct { - name string - existingPs []*plugin - expectedPs []*plugin + name string + plugin string + command string + generate GenerateFunc + error string }{ - {"no-plugins", []*plugin{}, []*plugin{pIns}}, - {"plugins-without-first", []*plugin{p1}, []*plugin{p1, pIns}}, - {"plugins-with-first", []*plugin{pf1, p2}, []*plugin{pf1, pIns, p2}}, - {"plugins-with-same-name", []*plugin{pf1, pIns, p2}, []*plugin{pf1, pIns, pIns, p2}}, - {"plugins-with-last", []*plugin{pf1, pl1}, []*plugin{pf1, pIns, pl1}}, - {"mixed", []*plugin{pf1, p1, p2}, []*plugin{pf1, p1, pIns, p2}}, + {name: "empty name", command: "gen", generate: unchangedFiles, error: "plugin name is empty"}, + {name: "unknown command", plugin: "plugin", command: "other", generate: unchangedFiles, error: `unknown generator command "other"`}, + {name: "missing generate", plugin: "plugin", command: "gen", error: "plugin generate function is nil"}, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - plugins = tc.existingPs - RegisterPlugin(pIns.name, "", nil, nil) - if !reflect.DeepEqual(plugins, tc.expectedPs) { - t.Errorf("invalid plugin registration order") - } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := pluginregistry.New() + require.PanicsWithValue(t, test.error, func() { + registerPluginIn(registry, test.plugin, test.command, pluginNormal, test.generate) + }) }) } } -func TestRegisterPluginFirst(t *testing.T) { - var ( - p1 = &plugin{name: "abc"} - p2 = &plugin{name: "def"} +// TestPluginRegistryKeepsDuplicateRegistrationOrder verifies that the released +// API keeps every callback when packages reuse the same command and name. +func TestPluginRegistryKeepsDuplicateRegistrationOrder(t *testing.T) { + registry := pluginregistry.New() + registerPluginIn(registry, "plugin", "gen", pluginFirst, unchangedFiles) + registerPluginIn(registry, "plugin", "gen", pluginLast, changedFiles) - pf1 = &plugin{name: "abc", first: true} - pf2 = &plugin{name: "def", first: true} + registrations := pluginregistry.SnapshotFrom[PrepareFunc, GenerateFunc](registry) + require.Len(t, registrations, 2) + require.Equal(t, pluginFirst, registrations[0].Position) + require.Equal(t, pluginLast, registrations[1].Position) + files, err := registrations[1].Generate("generated.local/gen", nil, nil) + require.NoError(t, err) + require.Equal(t, "changed", files[0].Path) + require.PanicsWithValue(t, "plugin registry is sealed", func() { + registerPluginIn(registry, "late", "gen", pluginNormal, unchangedFiles) + }) +} - pl1 = &plugin{name: "abc", last: true} +// TestPluginRegistrySnapshotIsCopied verifies that a caller cannot change the +// registrations retained for later generation runs. +func TestPluginRegistrySnapshotIsCopied(t *testing.T) { + registry := pluginregistry.New() + registerPluginIn(registry, "plugin", "gen", pluginNormal, unchangedFiles) - pIns = &plugin{name: "cde", first: true} - ) - tests := []struct { - name string - existingPs []*plugin - expectedPs []*plugin - }{ - {"no-plugins", []*plugin{}, []*plugin{pIns}}, - {"plugins-without-first", []*plugin{p1, p2}, []*plugin{pIns, p1, p2}}, - {"plugins-with-first", []*plugin{pf1, pf2}, []*plugin{pf1, pIns, pf2}}, - {"plugins-with-same-name", []*plugin{pf1, pIns}, []*plugin{pf1, pIns, pIns}}, - {"plugins-with-last", []*plugin{pf1, pl1}, []*plugin{pf1, pIns, pl1}}, - {"mixed", []*plugin{pf1, pf2, p1, p2}, []*plugin{pf1, pIns, pf2, p1, p2}}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - plugins = tc.existingPs - RegisterPluginFirst(pIns.name, "", nil, nil) - if !reflect.DeepEqual(plugins, tc.expectedPs) { - t.Errorf("invalid plugin registration order") - } - }) - } -} + first := pluginregistry.SnapshotFrom[PrepareFunc, GenerateFunc](registry) + first[0].Name = "changed" + second := pluginregistry.SnapshotFrom[PrepareFunc, GenerateFunc](registry) -func TestRegisterPluginLast(t *testing.T) { - var ( - p1 = &plugin{name: "abc"} - p2 = &plugin{name: "def"} + require.Equal(t, "plugin", second[0].Name) + require.Equal(t, "gen", second[0].Command) + require.Equal(t, pluginNormal, second[0].Position) +} - pl1 = &plugin{name: "abc", last: true} - pl2 = &plugin{name: "def", last: true} +// registerPluginIn applies the public registration checks to an isolated +// registry so the test does not stop later process-wide registrations. +func registerPluginIn(registry *pluginregistry.Registry, name, command string, position pluginPosition, generate GenerateFunc) { + validatePlugin(name, command, generate) + pluginregistry.RegisterIn[PrepareFunc, GenerateFunc](registry, name, command, position, nil, generate) +} - pf1 = &plugin{name: "abc", first: true} +// unchangedFiles provides a valid generation callback for registration tests. +func unchangedFiles(_ string, _ []eval.Root, files []*File) ([]*File, error) { + return files, nil +} - pIns = &plugin{name: "cde", last: true} - ) - tests := []struct { - name string - existingPs []*plugin - expectedPs []*plugin - }{ - {"no-plugins", []*plugin{}, []*plugin{pIns}}, - {"plugins-without-last", []*plugin{p1, p2}, []*plugin{p1, p2, pIns}}, - {"plugins-with-last", []*plugin{pl1, pl2}, []*plugin{pl1, pIns, pl2}}, - {"plugins-with-same-name", []*plugin{pl1, pIns}, []*plugin{pl1, pIns, pIns}}, - {"plugins-with-first", []*plugin{pf1, pl2}, []*plugin{pf1, pIns, pl2}}, - {"mixed", []*plugin{pf1, p1, p2, pl1, pl2}, []*plugin{pf1, p1, p2, pl1, pIns, pl2}}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - plugins = tc.existingPs - RegisterPluginLast(pIns.name, "", nil, nil) - if !reflect.DeepEqual(plugins, tc.expectedPs) { - t.Errorf("invalid plugin registration order") - } - }) - } +// changedFiles gives duplicate registration tests a distinct callback. +func changedFiles(_ string, _ []eval.Root, files []*File) ([]*File, error) { + return append(files, &File{Path: "changed"}), nil } diff --git a/codegen/protobuf.go b/codegen/protobuf.go new file mode 100644 index 0000000000..071d49e07e --- /dev/null +++ b/codegen/protobuf.go @@ -0,0 +1,73 @@ +// This file converts authored names into identifiers that Goa can safely write +// to protobuf files. Transport generators and external plugins use the same +// functions so identical design names produce identical protobuf names. +package codegen + +import ( + "regexp" + "strings" +) + +var ( + protobufDigits = regexp.MustCompile("[0-9]+") + + protobufKeywords = map[string]struct{}{ + "bool": {}, "bytes": {}, "double": {}, "fixed32": {}, "fixed64": {}, + "float": {}, "int32": {}, "int64": {}, "sfixed32": {}, "sfixed64": {}, + "sint32": {}, "sint64": {}, "string": {}, "uint32": {}, "uint64": {}, + "enum": {}, "import": {}, "map": {}, "message": {}, "oneof": {}, + "option": {}, "package": {}, "public": {}, "repeated": {}, "reserved": {}, + "returns": {}, "rpc": {}, "service": {}, "syntax": {}, + } +) + +// ProtobufName returns the identifier written for a protobuf message, service, +// or method. It keeps common acronyms uppercase and makes the first character +// legal for protobuf source. +func ProtobufName(name string) string { + return protobufIdentifier(name, true, true) +} + +// ProtobufFieldName returns the snake-case identifier written for a protobuf +// field or oneof. It makes the first character legal for protobuf source. +func ProtobufFieldName(name string) string { + name = SnakeCase(protobufIdentifier(name, false, false)) + if _, reserved := protobufKeywords[name]; reserved { + name += "_" + } + return name +} + +// protobufIdentifier removes characters protobuf identifiers cannot contain +// and separates digits so the generated Go name matches protoc-gen-go. +func protobufIdentifier(name string, firstUpper, acronym bool) string { + if index := strings.Index(name, ":"); index > 0 { + name = name[:index] + } + name = strings.Map(func(character rune) rune { + if character >= 'a' && character <= 'z' || + character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || + character == '_' { + return character + } + return '_' + }, name) + name = string(protobufDigits.ReplaceAllFunc([]byte(name), func(match []byte) []byte { + result := make([]byte, len(match)+1) + copy(result, match) + result[len(result)-1] = '_' + return result + })) + name = CamelCase(name, firstUpper, acronym) + if name == "" { + if firstUpper { + return "Val" + } + return "val" + } + if name[0] >= '0' && name[0] <= '9' { + name = "_" + name + } + return name +} diff --git a/codegen/scope.go b/codegen/scope.go index 1b23d1b02c..4578db72e7 100644 --- a/codegen/scope.go +++ b/codegen/scope.go @@ -1,3 +1,7 @@ +// Code generators use this file to turn caller-supplied type lookup keys and +// attributes into unique Go names and type references. Hashed names use the +// caller's exact Hash value. After Freeze, callers can read existing names but +// cannot reserve new ones. package codegen import ( @@ -14,6 +18,7 @@ type ( NameScope struct { names map[string]string // type hash to unique name counts map[string]int // raw type name to occurrence count + frozen bool // true after this set rejects new names } // Hasher is the interface implemented by the objects that must be @@ -38,15 +43,33 @@ func NewNameScope() *NameScope { } } +// Fork returns a new scope containing every lookup key and name already +// recorded in s. The new scope can add private helper names without changing s +// or colliding with names already chosen there. +func (s *NameScope) Fork() *NameScope { + fork := NewNameScope() + for hash, name := range s.names { + fork.names[hash] = name + } + for name, count := range s.counts { + fork.counts[name] = count + } + return fork +} + // HashedUnique builds the unique name for key using name and - if not unique - // appending suffix and - if still not unique - a counter value. It returns // the same value when called multiple times for a key returning the same hash. func (s *NameScope) HashedUnique(key Hasher, name string, suffix ...string) string { - if n, ok := s.names[key.Hash()]; ok { + hash := key.Hash() + if n, ok := s.names[hash]; ok { return n } + if s.frozen { + panic("cannot reserve a new hashed name in a frozen name scope") + } name = s.Unique(name, suffix...) - s.names[key.Hash()] = name + s.names[hash] = name return name } @@ -55,11 +78,37 @@ func (s *NameScope) HashedUnique(key Hasher, name string, suffix ...string) stri // counter value is added to the suffixed name until unique. The returned name // is reserved in the scope. func (s *NameScope) Unique(name string, suffix ...string) string { + if s.frozen { + panic("cannot reserve a name in a frozen name scope") + } ret := s.PeekUnique(name, suffix...) s.counts[ret]++ return ret } +// Freeze prevents the scope from reserving new names. Names already associated +// with hashes remain readable through HashedUnique and type-reference methods. +func (s *NameScope) Freeze() { + s.frozen = true +} + +// bind makes key return an already reserved Go name without reserving another +// name. Generated packages call it while Generation.Freeze assigns final +// declaration names. +func (s *NameScope) bind(key Hasher, name string) { + if s.frozen { + panic("cannot bind a hashed name in a frozen name scope") + } + hash := key.Hash() + if existing, ok := s.names[hash]; ok && existing != name { + panic(fmt.Sprintf("hash %q is already bound to package name %q", hash, existing)) + } + if _, ok := s.counts[name]; !ok { + panic(fmt.Sprintf("package name %q must be reserved before hash binding", name)) + } + s.names[hash] = name +} + // PeekUnique returns the name that Unique would return for the same inputs, // without mutating the scope. // @@ -272,8 +321,8 @@ func (s *NameScope) GoFullTypeName(att *expr.AttributeExpr, pkg string) string { s.GoFullTypeRef(actual.ElemType, pkgWithDefault(actual.ElemType.Type, pkg))) case *expr.Object: return s.GoTypeDef(att, false, false) - case expr.UserType, *expr.Union: - if actual == expr.ErrorResult { + case expr.UserType: + if expr.IsErrorResult(actual) { return "goa.ServiceError" } // Qualified type references (pkg.Type) do not compete in the local @@ -289,16 +338,9 @@ func (s *NameScope) GoFullTypeName(att *expr.AttributeExpr, pkg string) string { // consistent across packages. This is critical for transport packages that // refer to types defined in the service package (e.g., grpc referencing a // payload type defined as Request2). - base := Goify(actual.Name(), true) - if pkg == "" { - return s.HashedUnique(actual, base, "") - } - if UserTypeLocation(actual) == nil { - if n, ok := s.names[actual.Hash()]; ok { - return pkg + "." + n - } - } - return pkg + "." + base + return s.scopedTypeName(actual, Goify(actual.Name(), true), pkg) + case *expr.Union: + return s.scopedTypeName(NewUnionTypeID(actual), Goify(actual.Name(), true), pkg) case expr.CompositeExpr: return s.GoFullTypeName(actual.Attribute(), pkgWithDefault(actual.Attribute().Type, pkg)) default: @@ -306,6 +348,19 @@ func (s *NameScope) GoFullTypeName(att *expr.AttributeExpr, pkg string) string { } } +// scopedTypeName returns a local or package-qualified generated declaration +// name. key must be the lookup key recorded by the package containing that +// declaration. +func (s *NameScope) scopedTypeName(key Hasher, base, pkg string) string { + if pkg == "" { + return s.HashedUnique(key, base, "") + } + if name, ok := s.names[key.Hash()]; ok { + return pkg + "." + name + } + return pkg + "." + base +} + // pkgWithDefault returns the package defining the given type. If the types is a // user type with "struct:pkg:path" metadata then it returns the corresponding // value, otherwise it returns pkg. diff --git a/codegen/scope_test.go b/codegen/scope_test.go index d40f7436a1..fb0f699102 100644 --- a/codegen/scope_test.go +++ b/codegen/scope_test.go @@ -1,13 +1,55 @@ +// This file verifies package-level name allocation and generated type identity. package codegen import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "goa.design/goa/v3/expr" ) +type exactHasher string + +// Hash returns the exact map identity supplied by the test. +func (h exactHasher) Hash() string { + return string(h) +} + +func TestNameScope_Freeze(t *testing.T) { + scope := NewNameScope() + existing := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Existing", + UID: "existing", + } + require.Equal(t, "Existing", scope.GoTypeName(&expr.AttributeExpr{Type: existing})) + + scope.Freeze() + scope.Freeze() + require.Equal(t, "Existing", scope.GoTypeName(&expr.AttributeExpr{Type: existing})) + require.Equal(t, "Next", scope.PeekUnique("Next")) + require.Equal(t, "Next", scope.Name("Next")) + require.Panics(t, func() { + scope.Unique("Next") + }) + require.Panics(t, func() { + scope.HashedUnique(&expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Next", + UID: "next", + }, "Next") + }) + require.Panics(t, func() { + scope.GoTypeName(&expr.AttributeExpr{Type: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Indirect", + UID: "indirect", + }}) + }) +} + func TestNameScope_Unique(t *testing.T) { sequence := []struct { Input string @@ -41,6 +83,13 @@ func TestNameScope_Unique(t *testing.T) { } } +func TestNameScope_HashedUniqueUsesExactHash(t *testing.T) { + scope := NewNameScope() + require.Equal(t, "First", scope.HashedUnique(exactHasher("shared"), "First")) + require.Equal(t, "First", scope.HashedUnique(exactHasher("shared"), "Ignored")) + require.Equal(t, "First2", scope.HashedUnique(exactHasher("distinct"), "First")) +} + func TestNameScope_GoFullTypeName_UsesScopedNameWhenQualified(t *testing.T) { scope := NewNameScope() @@ -68,6 +117,312 @@ func TestNameScope_GoFullTypeName_UsesScopedNameWhenQualified(t *testing.T) { } } +func TestNameScope_GoFullTypeName_ReusesStructuralUnionNameWhenQualified(t *testing.T) { + scope := NewNameScope() + first := &expr.Union{TypeName: "Value"} + second := &expr.Union{TypeName: "Value"} + scope.GoTypeName(&expr.AttributeExpr{Type: first}) + secondAtt := &expr.AttributeExpr{Type: second} + if got, want := scope.GoTypeName(secondAtt), "Value"; got != want { + t.Errorf("GoTypeName() = %q, want %q", got, want) + } + if got, want := scope.GoFullTypeName(secondAtt, "types"), "types.Value"; got != want { + t.Errorf("GoFullTypeName() = %q, want %q", got, want) + } +} + +func TestNameScope_GoTypeNameDistinguishesUnionWireKeys(t *testing.T) { + scope := NewNameScope() + first := &expr.Union{TypeName: "Value", TypeKey: "type", ValueKey: "value"} + second := &expr.Union{TypeName: "Value", TypeKey: "kind", ValueKey: "data"} + assert.Equal(t, first.Hash(), second.Hash(), "compatibility hash should remain unchanged") + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +func TestNameScope_GoTypeNameDistinguishesUnionBranchPackages(t *testing.T) { + branch := func(path string) expr.UserType { + return &expr.UserTypeExpr{ + TypeName: "Entry", + UID: path, + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {path}}, + }, + } + } + first := &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: &expr.AttributeExpr{Type: branch("types/first")}}, + }, + } + second := &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: &expr.AttributeExpr{Type: branch("types/second")}}, + }, + } + assert.Equal(t, first.Hash(), second.Hash(), "compatibility hash should remain unchanged") + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +func TestNameScope_GoTypeNameDistinguishesUnionBranchOrder(t *testing.T) { + branch := func(name string) *expr.NamedAttributeExpr { + return &expr.NamedAttributeExpr{Name: name, Attribute: &expr.AttributeExpr{Type: expr.String}} + } + first := &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{branch("left"), branch("right")}} + second := &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{branch("right"), branch("left")}} + assert.Equal(t, first.Hash(), second.Hash(), "compatibility hash should remain unchanged") + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +func TestNameScope_GoTypeNameDistinguishesInlineObjectFieldOrder(t *testing.T) { + object := func(names ...string) *expr.Object { + fields := make(expr.Object, len(names)) + for i, name := range names { + fields[i] = &expr.NamedAttributeExpr{Name: name, Attribute: &expr.AttributeExpr{Type: expr.String}} + } + return &fields + } + union := func(fields *expr.Object) *expr.Union { + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "object", Attribute: &expr.AttributeExpr{Type: fields}}, + }, + } + } + first := union(object("left", "right")) + second := union(object("right", "left")) + assert.Equal(t, first.Hash(), second.Hash(), "compatibility hash should remain unchanged") + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +// TestNameScope_GoTypeNameSharesIdenticalEmittedUnionDefinitions verifies +// semantically different branch declarations share a structurally equal union. +func TestNameScope_GoTypeNameSharesIdenticalEmittedUnionDefinitions(t *testing.T) { + branch := func(name, id string) expr.UserType { + return &expr.UserTypeExpr{ + TypeName: name, + UID: id, + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + } + } + union := func(user expr.UserType) *expr.Union { + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: &expr.AttributeExpr{Type: user}}, + }, + } + } + first := union(branch("foo-bar", "first")) + second := union(branch("foo_bar", "second")) + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +// TestNameScopeForkPreservesBindingsAndAcceptsHelperNames verifies a frozen +// declaration scope can seed a separate mutable helper namespace. +func TestNameScopeForkPreservesBindingsAndAcceptsHelperNames(t *testing.T) { + typeExpr := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Value", + } + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: typeExpr})) + scope.Freeze() + + fork := scope.Fork() + assert.Equal(t, "Value", fork.GoTypeName(&expr.AttributeExpr{Type: typeExpr})) + assert.Equal(t, "Value2", fork.Unique("Value")) + assert.Equal(t, "helper", fork.Unique("helper")) +} + +func TestUnionTypeID(t *testing.T) { + branch := func(name string, dataType expr.DataType) *expr.NamedAttributeExpr { + return &expr.NamedAttributeExpr{Name: name, Attribute: &expr.AttributeExpr{Type: dataType}} + } + userType := func(path string) expr.UserType { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {path}}, + }, + TypeName: "Entry", + UID: "entry", + } + } + tests := []struct { + name string + first *expr.Union + second *expr.Union + }{ + { + name: "wire keys", + first: &expr.Union{TypeName: "Value", TypeKey: "type", ValueKey: "value"}, + second: &expr.Union{TypeName: "Value", TypeKey: "kind", ValueKey: "data"}, + }, + { + name: "branch order", + first: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("left", expr.String), + branch("right", expr.Int), + }}, + second: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("right", expr.Int), + branch("left", expr.String), + }}, + }, + { + name: "branch Go shape", + first: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("entry", expr.String), + }}, + second: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:field:type": {"CustomString"}}, + }}, + }}, + }, + { + name: "relocated branch package", + first: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("entry", userType("types/first")), + }}, + second: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("entry", userType("types/second")), + }}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NotEqual(t, NewUnionTypeID(test.first), NewUnionTypeID(test.second)) + }) + } +} + +func TestUnionTypeIDIgnoresNonEmittedPointerSharing(t *testing.T) { + object := func() *expr.Object { + fields := expr.Object{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + } + return &fields + } + innerUnion := func() *expr.Union { + return &expr.Union{ + TypeName: "Inner", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + } + } + outerUnion := func(left, right expr.DataType) *expr.Union { + return &expr.Union{ + TypeName: "Outer", + Values: []*expr.NamedAttributeExpr{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: left}}, + {Name: "right", Attribute: &expr.AttributeExpr{Type: right}}, + }, + } + } + + t.Run("inline object", func(t *testing.T) { + shared := object() + assert.Equal(t, NewUnionTypeID(outerUnion(shared, shared)), NewUnionTypeID(outerUnion(object(), object()))) + }) + t.Run("nested union", func(t *testing.T) { + shared := innerUnion() + assert.Equal(t, NewUnionTypeID(outerUnion(shared, shared)), NewUnionTypeID(outerUnion(innerUnion(), innerUnion()))) + }) +} + +// TestUnionTypeIDIncludesGeneratedUserTypeShape verifies generated aliases +// with one name and distinct definitions produce distinct union identities. +func TestUnionTypeIDIncludesGeneratedUserTypeShape(t *testing.T) { + generatedBranch := func(dataType expr.DataType) *expr.Union { + alias := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: dataType}, + TypeName: "ValueText", + } + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{{ + Name: "text", + Attribute: &expr.AttributeExpr{Type: alias}, + }}, + } + } + + require.NotEqual(t, + NewUnionTypeID(generatedBranch(expr.String)), + NewUnionTypeID(generatedBranch(expr.Int)), + ) +} + +// TestUnionTypeIDEncodesRecursiveGeneratedUserTypeShape verifies recursive +// generated aliases terminate and retain their distinct field definitions. +func TestUnionTypeIDEncodesRecursiveGeneratedUserTypeShape(t *testing.T) { + recursiveUnion := func(fieldType expr.DataType) *expr.Union { + alias := &expr.UserTypeExpr{TypeName: "ValueNode"} + alias.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: fieldType}}, + {Name: "next", Attribute: &expr.AttributeExpr{Type: alias}}, + }} + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "node", Attribute: &expr.AttributeExpr{Type: alias}}, + }, + } + } + + require.NotEqual(t, + NewUnionTypeID(recursiveUnion(expr.String)), + NewUnionTypeID(recursiveUnion(expr.Int)), + ) +} + +func TestNameScope_GoFullTypeName_UsesScopedRelocatedUserTypeNameWhenQualified(t *testing.T) { + scope := NewNameScope() + first := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + TypeName: "foo-bar", + UID: "first", + } + second := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + TypeName: "foo_bar", + UID: "second", + } + scope.GoTypeName(&expr.AttributeExpr{Type: first}) + secondAtt := &expr.AttributeExpr{Type: second} + if got, want := scope.GoTypeName(secondAtt), "FooBar2"; got != want { + t.Fatalf("GoTypeName() = %q, want %q", got, want) + } + if got, want := scope.GoFullTypeName(secondAtt, "types"), "types.FooBar2"; got != want { + t.Errorf("GoFullTypeName() = %q, want %q", got, want) + } +} + func TestNameScope_PeekUnique_MatchesUniqueWithoutMutation(t *testing.T) { seed := func(scope *NameScope) { scope.Unique("a") diff --git a/codegen/sections_test.go b/codegen/sections_test.go index b810de4576..c3f142b042 100644 --- a/codegen/sections_test.go +++ b/codegen/sections_test.go @@ -122,3 +122,45 @@ package testpackage }) } } + +func TestHeaderKeepsOneImportPerPath(t *testing.T) { + section := Header("", "testpackage", []*ImportSpec{ + {Path: "encoding/json"}, + {Name: "json", Path: "encoding/json"}, + }) + var source bytes.Buffer + if err := section.Write(&source); err != nil { + t.Fatal(err) + } + if count := strings.Count(source.String(), `"encoding/json"`); count != 1 { + t.Fatalf("encoding/json import count = %d, want 1\n%s", count, source.String()) + } + if !strings.Contains(source.String(), `json "encoding/json"`) { + t.Fatalf("encoding/json import did not keep its explicit name\n%s", source.String()) + } +} + +func TestAddImportKeepsOneImportPerPath(t *testing.T) { + section := Header("", "testpackage", []*ImportSpec{{Path: "encoding/json"}}) + AddImport(section, &ImportSpec{Name: "json", Path: "encoding/json"}) + var source bytes.Buffer + if err := section.Write(&source); err != nil { + t.Fatal(err) + } + if count := strings.Count(source.String(), `"encoding/json"`); count != 1 { + t.Fatalf("encoding/json import count = %d, want 1\n%s", count, source.String()) + } + if !strings.Contains(source.String(), `json "encoding/json"`) { + t.Fatalf("encoding/json import did not keep its explicit name\n%s", source.String()) + } + + t.Run("different explicit names", func(t *testing.T) { + section := Header("", "testpackage", []*ImportSpec{{Name: "first", Path: "example.com/log"}}) + defer func() { + if recovered := recover(); recovered == nil { + t.Fatal("AddImport did not reject different explicit names for one package") + } + }() + AddImport(section, &ImportSpec{Name: "second", Path: "example.com/log"}) + }) +} diff --git a/codegen/service/client.go b/codegen/service/client.go index cbe861f665..6a1be96650 100644 --- a/codegen/service/client.go +++ b/codegen/service/client.go @@ -1,32 +1,24 @@ +// This file renders one service's in-process client and includes only the type +// imports used by that generated client file. package service import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -const ( - // clientStructName is the name of the generated client data structure. - clientStructName = "Client" -) - -// ClientFile returns the client file for the given service. -func ClientFile(_ string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { - svc := services.Get(service.Name) +// clientFile renders the client from the service data copied into plan. +func clientFile(plan *Plan, facts *serviceFacts) *codegen.File { + services := plan.Services() + svc := services.Get(facts.name) data := endpointData(svc) path := filepath.Join(codegen.Gendir, svc.PathName, "client.go") var ( sections []*codegen.SectionTemplate ) { - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "io"}, - codegen.GoaImport(""), - } - header := codegen.Header(service.Name+" client", svc.PkgName, imports) + header := codegen.Header(facts.name+" client", svc.PkgName, facts.imports.client.specs) def := &codegen.SectionTemplate{ Name: "client-struct", Source: serviceTemplates.Read(serviceClientT), diff --git a/codegen/service/client_test.go b/codegen/service/client_test.go index 54a7505f5b..dac329e3f3 100644 --- a/codegen/service/client_test.go +++ b/codegen/service/client_test.go @@ -38,9 +38,9 @@ func TestClient(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := ClientFile("test/gen", root.Services[0], services) + fs := clientFile(plan, plan.facts.services[0]) require.NotNil(t, fs) buf := new(bytes.Buffer) for _, s := range fs.SectionTemplates[1:] { diff --git a/codegen/service/codegen_specialization_test.go b/codegen/service/codegen_specialization_test.go new file mode 100644 index 0000000000..0cf86c52a5 --- /dev/null +++ b/codegen/service/codegen_specialization_test.go @@ -0,0 +1,277 @@ +// This file verifies that service generation omits runtime work whose answer +// is fixed by the evaluated design. +package service + +import ( + "bytes" + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestInterceptorAccessorsDoNotRediscoverPlannedMethods catches generated +// accessors that switch on the method name or the planned payload wrapper. +func TestInterceptorAccessorsDoNotRediscoverPlannedMethods(t *testing.T) { + root := codegen.RunDSL(t, interceptorSpecializationDSL) + plan := retainedServicePlanForPackage(t, root) + files := interceptorsFiles(plan, plan.facts.services[0]) + + var rendered strings.Builder + for _, file := range files { + var source bytes.Buffer + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + parsed, err := parser.ParseFile(token.NewFileSet(), file.Path, source.Bytes(), 0) + require.NoError(t, err, source.String()) + ast.Inspect(parsed, func(node ast.Node) bool { + switch node.(type) { + case *ast.SwitchStmt, *ast.TypeSwitchStmt: + t.Errorf("%s contains a runtime switch for a planned interceptor fact", file.Path) + } + return true + }) + rendered.Write(source.Bytes()) + } + + code := rendered.String() + require.Contains(t, code, "InspectInfo interface") + require.NotContains(t, code, "method string") + require.NotContains(t, code, "callType goa.InterceptorCallType") + + generated, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFilesWith(t, generated, map[string]string{ + "gen/interceptor_specialization/info_specialization_test.go": interceptorInfoRuntimeTest, + }) +} + +// TestSharedInterceptorSpecializesDifferentClientAndServerMethods catches a +// client method implementation lost when the same interceptor is also used by +// a different server method. +func TestSharedInterceptorSpecializesDifferentClientAndServerMethods(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Interceptor("inspect", func() { + dsl.ReadPayload(func() { + dsl.Attribute("value") + }) + }) + dsl.Service("SplitInterceptorMethods", func() { + dsl.Method("ServerOnly", func() { + dsl.ServerInterceptor("inspect") + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + dsl.Method("ClientOnly", func() { + dsl.ClientInterceptor("inspect") + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) +} + +// TestEmptyProjectedValidatorsAreOmitted catches public validation functions +// and parent calls that cannot report an error for any value. +func TestEmptyProjectedValidatorsAreOmitted(t *testing.T) { + root := codegen.RunDSL(t, func() { + empty := dsl.ResultType("application/vnd.empty", func() { + dsl.TypeName("Empty") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("EmptyViews", func() { + dsl.Method("Read", func() { + dsl.Result(empty) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + data := plan.Services().Get("EmptyViews") + + require.Len(t, data.projectedTypes, 1) + require.Empty(t, data.projectedTypes[0].Validations) + require.Len(t, data.viewedResultTypes, 1) + require.Len(t, data.viewedResultTypes[0].Validate.Calls, 1) + require.Nil(t, data.viewedResultTypes[0].Validate.Calls[0].Declaration) + + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) +} + +// TestRequiredParentOmitsEmptyChildCall catches removal of the parent's +// missing-field check when its selected child view has no other rules. +func TestRequiredParentOmitsEmptyChildCall(t *testing.T) { + root := codegen.RunDSL(t, func() { + child := dsl.ResultType("application/vnd.empty-child", func() { + dsl.TypeName("EmptyChild") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + parent := dsl.ResultType("application/vnd.required-parent", func() { + dsl.TypeName("RequiredParent") + dsl.Attribute("child", child) + dsl.Required("child") + dsl.View("default", func() { + dsl.Attribute("child") + }) + }) + dsl.Service("RequiredParentViews", func() { + dsl.Method("Read", func() { + dsl.Result(parent) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + data := plan.Services().Get("RequiredParentViews") + + var parentValidation *ValidateData + for _, projected := range data.projectedTypes { + switch projected.Name { + case "EmptyChildView": + require.Empty(t, projected.Validations) + case "RequiredParentView": + require.Len(t, projected.Validations, 1) + parentValidation = projected.Validations[0] + } + } + require.NotNil(t, parentValidation) + require.Empty(t, parentValidation.Calls) + require.Contains(t, parentValidation.Validate, `MissingFieldError("child", "result")`) + + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) +} + +// TestEmptyRecursiveProjectedValidatorsAreOmitted catches cycles that retain +// validators even though no node in the cycle can report an error. +func TestEmptyRecursiveProjectedValidatorsAreOmitted(t *testing.T) { + root := codegen.RunDSL(t, func() { + tree := dsl.ResultType("application/vnd.empty-tree", func() { + dsl.TypeName("EmptyTree") + dsl.Attribute("next", "EmptyTree") + dsl.View("default", func() { + dsl.Attribute("next") + }) + }) + dsl.Service("EmptyRecursiveViews", func() { + dsl.Method("Read", func() { + dsl.Result(tree) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + data := plan.Services().Get("EmptyRecursiveViews") + + require.Len(t, data.projectedTypes, 1) + require.Empty(t, data.projectedTypes[0].Validations) + + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) +} + +// interceptorSpecializationDSL applies one interceptor to multiple methods so +// generated accessors must select exact method and streaming types in advance. +func interceptorSpecializationDSL() { + dsl.Interceptor("inspect", func() { + dsl.ReadPayload(func() { + dsl.Attribute("initial") + }) + dsl.ReadStreamingPayload(func() { + dsl.Attribute("input") + }) + dsl.ReadStreamingResult(func() { + dsl.Attribute("output") + }) + }) + dsl.Service("InterceptorSpecialization", func() { + dsl.ServerInterceptor("inspect") + dsl.ClientInterceptor("inspect") + for _, name := range []string{"First", "Second"} { + dsl.Method(name, func() { + dsl.Payload(func() { + dsl.Field(1, "initial", dsl.String) + }) + dsl.StreamingPayload(func() { + dsl.Field(1, "input", dsl.String) + }) + dsl.StreamingResult(func() { + dsl.Field(1, "output", dsl.String) + }) + dsl.GRPC(func() {}) + }) + } + }) +} + +const interceptorInfoRuntimeTest = `package interceptorspecialization + +import ( + "testing" + + goa "goa.design/goa/v3/pkg" +) + +func TestSpecializedInterceptorInfo(t *testing.T) { + initial := "start" + input := "in" + output := "out" + payload := &FirstPayload{Initial: &initial} + streamingPayload := &FirstStreamingPayload{Input: &input} + streamingResult := &FirstResult{Output: &output} + + server := &inspectFirstServerUnaryInfo{inspectFirstInfo: &inspectFirstInfo{ + rawPayload: &FirstEndpointInput{Payload: payload}, + }} + if server.Service() != "InterceptorSpecialization" || server.Method() != "First" || server.CallType() != goa.InterceptorUnary { + t.Errorf("unexpected server metadata: %s %s %v", server.Service(), server.Method(), server.CallType()) + } + if actual := server.Payload().Initial(); actual != initial { + t.Errorf("server payload = %q, want %q", actual, initial) + } + + client := &inspectFirstClientUnaryInfo{inspectFirstInfo: &inspectFirstInfo{rawPayload: payload}} + if client.CallType() != goa.InterceptorUnary || client.Payload().Initial() != initial { + t.Errorf("unexpected client endpoint metadata") + } + + send := &inspectFirstStreamingSendInfo{inspectFirstInfo: &inspectFirstInfo{rawPayload: streamingResult}} + if send.CallType() != goa.InterceptorStreamingSend || send.ServerStreamingResult().Output() != output { + t.Errorf("unexpected server send metadata") + } + + recv := &inspectFirstStreamingRecvInfo{inspectFirstInfo: &inspectFirstInfo{}} + if recv.CallType() != goa.InterceptorStreamingRecv || recv.ServerStreamingPayload(streamingPayload).Input() != input { + t.Errorf("unexpected server receive metadata") + } + + clientSend := &inspectFirstStreamingSendInfo{inspectFirstInfo: &inspectFirstInfo{rawPayload: streamingPayload}} + if clientSend.ClientStreamingPayload().Input() != input { + t.Errorf("unexpected client send metadata") + } + clientRecv := &inspectFirstStreamingRecvInfo{inspectFirstInfo: &inspectFirstInfo{}} + if clientRecv.ClientStreamingResult(streamingResult).Output() != output { + t.Errorf("unexpected client receive metadata") + } +} +` diff --git a/codegen/service/conversion_plan.go b/codegen/service/conversion_plan.go new file mode 100644 index 0000000000..03ac948d56 --- /dev/null +++ b/codegen/service/conversion_plan.go @@ -0,0 +1,566 @@ +// This file records external Go type conversions before generated names are +// chosen. It stores each conversion, its recursive functions, and its imports. +package service + +import ( + "cmp" + "fmt" + "reflect" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // externalConversionDirection identifies whether a generated method converts + // to or from a user-supplied Go type. + externalConversionDirection uint8 + + // externalConversionNameOrder orders child conversion helpers by their + // service, method, and field position instead of discovery order. + externalConversionNameOrder struct { + receiverID string + externalPkg string + external string + direction externalConversionDirection + source string + target string + occurrence int + required bool + } + + // externalConversionFacts stores one conversion between a Goa type and a + // user-supplied Go type, including conversions for nested fields. + externalConversionFacts struct { + direction externalConversionDirection + serviceName string + servicePath string + receiverID string + receiverAttribute *expr.AttributeExpr + receiverType *codegen.TypeDeclaration + externalType reflect.Type + externalPath string + externalAlias string + externalAttribute *expr.AttributeExpr + externalPackages map[expr.UserType]string + externalScope *codegen.NameScope + plan *codegen.TransformPlan + methodName string + data *convertData + helpers []*codegen.TransformFunctionData + } + + // externalConversionFileFacts groups the conversion operations and imports + // emitted by one generated receiver package's convert.go file. + externalConversionFileFacts struct { + owner *codegen.GeneratedPackage + operations []*externalConversionFacts + imports retainedFileImports + } + + // externalConversionIdentity selects one generated method by its receiver, + // conversion direction, and user-supplied Go type across all designs in the + // generation command. + externalConversionIdentity struct { + receiver *codegen.TypeDeclaration + direction externalConversionDirection + externalType reflect.Type + externalPath string + } + + // externalConversionResolver writes each user-supplied type with the import + // name chosen for the package that declares it. + externalConversionResolver struct { + scope *codegen.AttributeScope + packages map[expr.UserType]string + } +) + +const ( + externalConvertTo externalConversionDirection = iota + 1 + externalCreateFrom +) + +// collectExternalConversions records every conversion once in the package that +// declares its receiver type. When several designs use the same receiver +// package, Goa writes one set of method names and one convert.go file. +func collectExternalConversions(roots []*rootFacts, generation *codegen.Generation) error { + files := make(map[*codegen.GeneratedPackage]*externalConversionFileFacts) + fileRoots := make(map[*codegen.GeneratedPackage]*rootFacts) + serviceRoots := make(map[*serviceFacts]*rootFacts) + operations := make(map[externalConversionIdentity]struct{}) + for _, root := range roots { + root.externalConversions = nil + for _, service := range root.services { + serviceRoots[service] = root + } + } + collect := func(mappings []*expr.TypeMap, direction externalConversionDirection) error { + for _, mapping := range mappings { + owners := make(map[*codegen.GeneratedPackage]*serviceFacts) + for _, candidate := range roots { + for _, service := range candidate.services { + if !typeMapMatchesFacts(mapping, service) { + continue + } + owner := generation.Package(generatedPackagePath( + generation.GenPkg(), service.packagePath, codegen.UserTypeLocation(mapping.User), + )) + selected := owners[owner] + if selected == nil || service.packagePath < selected.packagePath { + owners[owner] = service + } + } + } + orderedOwners := make([]*codegen.GeneratedPackage, 0, len(owners)) + for owner := range owners { + orderedOwners = append(orderedOwners, owner) + } + sort.Slice(orderedOwners, func(i, j int) bool { + return orderedOwners[i].ImportPath() < orderedOwners[j].ImportPath() + }) + for _, owner := range orderedOwners { + identity, externalAlias, err := identifyExternalConversion(mapping, owner, direction) + if err != nil { + return err + } + if _, exists := operations[identity]; exists { + return fmt.Errorf( + "duplicate external conversion for receiver %q in package %q and external type %q", + mapping.User.ID(), + identity.receiver.PackagePath(), + identity.externalType.String(), + ) + } + operations[identity] = struct{}{} + operation, err := planExternalConversion( + owners[owner], mapping, owner, identity, externalAlias, + ) + if err != nil { + return err + } + file := files[owner] + if file == nil { + file = &externalConversionFileFacts{owner: owner} + files[owner] = file + } + file.operations = append(file.operations, operation) + candidateRoot := serviceRoots[owners[owner]] + selectedRoot := fileRoots[owner] + if selectedRoot == nil || rootFactsOrder(candidateRoot) < rootFactsOrder(selectedRoot) { + fileRoots[owner] = candidateRoot + } + } + } + return nil + } + for _, root := range roots { + if err := collect(root.root.Conversions, externalConvertTo); err != nil { + return err + } + if err := collect(root.root.Creations, externalCreateFrom); err != nil { + return err + } + } + + for _, file := range files { + if err := finishExternalConversionFile(file, generation); err != nil { + return err + } + owner := fileRoots[file.owner] + owner.externalConversions = append(owner.externalConversions, file) + } + for _, root := range roots { + sort.Slice(root.externalConversions, func(i, j int) bool { + return root.externalConversions[i].owner.ImportPath() < root.externalConversions[j].owner.ImportPath() + }) + } + return nil +} + +// identifyExternalConversion identifies the generated receiver method before +// Goa submits its helper names and imports. +func identifyExternalConversion(mapping *expr.TypeMap, owner *codegen.GeneratedPackage, direction externalConversionDirection) (externalConversionIdentity, string, error) { + externalType := reflect.TypeOf(mapping.External) + if externalType == nil { + return externalConversionIdentity{}, "", fmt.Errorf("external conversion type must not be nil") + } + externalPath, externalAlias, err := getExternalReflectTypeInfo(externalType) + if err != nil { + return externalConversionIdentity{}, "", err + } + receiver, err := owner.Type(mapping.User) + if err != nil { + return externalConversionIdentity{}, "", err + } + return externalConversionIdentity{ + receiver: receiver, + direction: direction, + externalType: externalType, + externalPath: externalPath, + }, externalAlias, nil +} + +// rootFactsOrder returns the API and service names used to order definitions +// shared by several designs. +func rootFactsOrder(facts *rootFacts) string { + paths := make([]string, len(facts.services)) + for index, service := range facts.services { + paths[index] = service.packagePath + } + sort.Strings(paths) + return facts.apiName + "\x00" + strings.Join(paths, "\x00") +} + +// externalConversionFiles returns one convert.go description per receiver +// package and rejects two service plans that both try to write that file. +func externalConversionFiles(plans []*Plan) ([]*externalConversionFileFacts, error) { + byOwner := make(map[*codegen.GeneratedPackage]struct{}) + var files []*externalConversionFileFacts + for _, plan := range plans { + for _, retained := range plan.facts.externalConversions { + if _, exists := byOwner[retained.owner]; exists { + return nil, fmt.Errorf( + "external conversion package %q was assigned to more than one service plan", + retained.owner.ImportPath(), + ) + } + byOwner[retained.owner] = struct{}{} + files = append(files, retained) + } + } + sort.Slice(files, func(i, j int) bool { + return files[i].owner.ImportPath() < files[j].owner.ImportPath() + }) + return files, nil +} + +// planExternalConversion reads one user-supplied Go type, records the complete +// field conversion, and submits each child helper to the receiver's package. +func planExternalConversion( + service *serviceFacts, + mapping *expr.TypeMap, + owner *codegen.GeneratedPackage, + identity externalConversionIdentity, + externalAlias string, +) (*externalConversionFacts, error) { + externalType := identity.externalType + externalDataType, reflectedTypes, err := buildExternalDesignType(externalType, mapping.User) + if err != nil { + return nil, err + } + externalPackages := make(map[expr.UserType]string, len(reflectedTypes)) + for userType, reflected := range reflectedTypes { + importPath, alias, err := getExternalReflectTypeInfo(reflected) + if err != nil { + return nil, err + } + if err := owner.DeclareImport(codegen.NewImport(alias, importPath)); err != nil { + return nil, err + } + externalPackages[userType.Origin()] = importPath + } + externalPath := identity.externalPath + externalAttribute := &expr.AttributeExpr{Type: externalDataType} + if identity.direction == externalConvertTo { + externalAttribute.AddMeta("struct:type:name", externalDataType.Name()) + } + receiverAttribute := expr.DupAtt(&expr.AttributeExpr{Type: mapping.User}) + source := receiverAttribute + target := externalAttribute + if identity.direction == externalCreateFrom { + source, target = externalAttribute, source + } + transform, err := codegen.NewTransformPlan(source, target, "", nil) + if err != nil { + return nil, err + } + operation := &externalConversionFacts{ + direction: identity.direction, + serviceName: service.name, + servicePath: service.packagePath, + receiverID: mapping.User.ID(), + receiverAttribute: receiverAttribute, + externalType: externalType, + externalPath: externalPath, + externalAlias: externalAlias, + externalAttribute: externalAttribute, + externalPackages: externalPackages, + externalScope: codegen.NewNameScope(), + plan: transform, + receiverType: identity.receiver, + } + for _, helper := range transform.Helpers() { + sourceName, sourceID := transformDataTypeName(helper.Source.Type) + targetName, targetID := transformDataTypeName(helper.Target.Type) + if identity.direction == externalConvertTo { + targetName = externalAlias + codegen.Goify(targetName, true) + } else { + sourceName = externalAlias + codegen.Goify(sourceName, true) + } + order := externalConversionNameOrder{ + receiverID: mapping.User.ID(), + externalPkg: externalPath, + external: externalType.Name(), + direction: identity.direction, + source: sourceID, + target: targetID, + occurrence: helper.Occurrence, + required: helper.Required, + } + declaration := codegen.NewPreferredName( + codegen.NameFunction, + "transform"+codegen.Goify(sourceName, true)+"To"+codegen.Goify(targetName, true), + codegen.UnexportedName, + order, + ) + if err := owner.DeclareName(declaration); err != nil { + return nil, err + } + if err := transform.BindHelperDeclaration(helper.ID, declaration); err != nil { + return nil, err + } + } + return operation, nil +} + +// finishExternalConversionFile sorts the generated methods, assigns child +// helper names within each receiver method, and records the imports used by +// convert.go. +func finishExternalConversionFile(file *externalConversionFileFacts, generation *codegen.Generation) error { + sort.Slice(file.operations, func(i, j int) bool { + return externalConversionOperationLess(file.operations[i], file.operations[j]) + }) + takenByReceiver := make(map[*codegen.TypeDeclaration]map[string]struct{}) + for _, operation := range file.operations { + receiver := operation.receiverType + taken := takenByReceiver[receiver] + if taken == nil { + taken = make(map[string]struct{}) + takenByReceiver[receiver] = taken + } + prefix := "ConvertTo" + if operation.direction == externalCreateFrom { + prefix = "CreateFrom" + } + operation.methodName = uniquify(prefix+operation.externalType.Name(), taken) + } + + definitions := make([]*expr.AttributeExpr, 0, len(file.operations)*2) + references := make([]*expr.AttributeExpr, 0, len(file.operations)*2) + for _, operation := range file.operations { + definitions = append(definitions, operation.receiverAttribute, operation.externalAttribute) + references = append(references, operation.receiverAttribute, operation.externalAttribute) + } + imports, err := retainFileImports( + generation, + file.owner.ImportPath(), + nil, + nil, + definitions, + references, + ) + if err != nil { + return err + } + for _, operation := range file.operations { + for _, importPath := range operation.externalPackages { + addRetainedImportPath(&imports, importPath) + } + } + file.imports = imports + return nil +} + +// externalConversionOperationLess orders generated receiver methods by their +// source and target types, service, method, and direction. +func externalConversionOperationLess(left, right *externalConversionFacts) bool { + if left.receiverID != right.receiverID { + return left.receiverID < right.receiverID + } + if left.direction != right.direction { + return left.direction < right.direction + } + if left.externalPath != right.externalPath { + return left.externalPath < right.externalPath + } + return left.externalType.Name() < right.externalType.Name() +} + +// linkExternalConversions adds the chosen import names and formats every +// previously recorded conversion without reading Go types or creating helpers. +func linkExternalConversions( + facts *rootFacts, + generation *codegen.Generation, + aliases *importAliases, +) error { + for _, file := range facts.externalConversions { + linkFileImports(&file.imports, generation) + for _, operation := range file.operations { + serviceResolver := newServiceResolver( + generation, + aliases, + operation.serviceName, + operation.servicePath, + file.owner.ImportPath(), + ) + if err := linkExternalConversion(operation, serviceResolver, aliases); err != nil { + return err + } + } + } + return nil +} + +// linkExternalConversion formats one recorded field conversion with the Go +// type and import names selected for its output file. +func linkExternalConversion( + operation *externalConversionFacts, + serviceResolver *declarationResolver, + aliases *importAliases, +) error { + externalResolver := newExternalConversionResolver( + operation.externalScope, + operation.externalPackages, + aliases, + serviceResolver.outputPath, + ) + externalContext := &codegen.AttributeContext{ + Scope: externalResolver, + } + serviceContext := &codegen.AttributeContext{ + UseDefault: true, + Scope: serviceResolver, + } + sourceContext, targetContext := serviceContext, externalContext + sourceVar, targetVar := "t", "v" + if operation.direction == externalCreateFrom { + sourceContext, targetContext = externalContext, serviceContext + sourceVar, targetVar = "v", "temp" + } + if err := operation.plan.BindContexts(sourceContext, targetContext); err != nil { + return err + } + code, helpers, err := operation.plan.Render(sourceVar, targetVar, true) + if err != nil { + return err + } + operation.data = &convertData{ + Name: operation.methodName, + ReceiverTypeRef: "*" + operation.receiverType.Name(), + TypeRef: externalResolver.Ref( + operation.externalAttribute, + externalResolver.Package(operation.externalAttribute), + ), + Code: code, + } + if operation.direction == externalConvertTo { + operation.data.TypeName = operation.externalType.Name() + } + operation.helpers = helpers + return nil +} + +// newExternalConversionResolver associates each user-supplied Go type with the +// import name selected for its package. +func newExternalConversionResolver( + scope *codegen.NameScope, + packages map[expr.UserType]string, + aliases *importAliases, + outputPackage string, +) *externalConversionResolver { + resolved := make(map[expr.UserType]string, len(packages)) + for userType, importPath := range packages { + resolved[userType.Origin()] = aliases.name(outputPackage, importPath) + } + return &externalConversionResolver{ + scope: codegen.NewAttributeScope(scope), + packages: resolved, + } +} + +// Name renders an external reflected type with the alias for its own package. +func (r *externalConversionResolver) Name(att *expr.AttributeExpr, pkg string, ptr, useDefault bool) string { + if userType, ok := att.Type.(expr.UserType); ok { + pkg = r.packageName(userType) + } + return r.scope.Name(att, pkg, ptr, useDefault) +} + +// Ref renders an external reflected type reference with its own package alias. +func (r *externalConversionResolver) Ref(att *expr.AttributeExpr, pkg string) string { + if userType, ok := att.Type.(expr.UserType); ok { + pkg = r.packageName(userType) + } + return r.scope.Ref(att, pkg) +} + +// Field returns the reflected Go struct field selected for name. +func (r *externalConversionResolver) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { + return r.scope.Field(att, name, firstUpper) +} + +// Package returns the Go name written before a user-supplied named type. +func (r *externalConversionResolver) Package(att *expr.AttributeExpr) string { + if userType, ok := att.Type.(expr.UserType); ok { + return r.packageName(userType) + } + return "" +} + +// Enter returns the same resolver because each child named type already records +// the package that declares it. +func (r *externalConversionResolver) Enter(*expr.AttributeExpr) codegen.Attributor { + return r +} + +// IsSumType reports the standard Goa transform representation. +func (r *externalConversionResolver) IsSumType() bool { + return r.scope.IsSumType() +} + +// ValidatorCall is not part of external conversion rendering. +func (*externalConversionResolver) ValidatorCall(*expr.AttributeExpr, string, string, string) string { + panic("external conversion resolver does not own validators") +} + +// Scope returns the name set used to prevent generated field and local variable +// names from colliding. +func (r *externalConversionResolver) Scope() *codegen.NameScope { + return r.scope.Scope() +} + +// packageName returns the Go import name chosen for one user-supplied type. +func (r *externalConversionResolver) packageName(userType expr.UserType) string { + name, ok := r.packages[userType.Origin()] + if !ok { + panic(fmt.Sprintf("external reflected type %q has no planned package alias", userType.Name())) + } + return name +} + +// ComparePackageName orders conversion helpers by their source and target +// types, service, method, and direction instead of discovery order. +func (o externalConversionNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(externalConversionNameOrder) + required := 0 + if o.required != right.required { + if o.required { + required = 1 + } else { + required = -1 + } + } + return cmp.Or( + strings.Compare(o.receiverID, right.receiverID), + strings.Compare(o.externalPkg, right.externalPkg), + strings.Compare(o.external, right.external), + cmp.Compare(o.direction, right.direction), + strings.Compare(o.source, right.source), + strings.Compare(o.target, right.target), + cmp.Compare(o.occurrence, right.occurrence), + required, + ) +} diff --git a/codegen/service/conversion_plan_contract_test.go b/codegen/service/conversion_plan_contract_test.go new file mode 100644 index 0000000000..19d4707e2e --- /dev/null +++ b/codegen/service/conversion_plan_contract_test.go @@ -0,0 +1,387 @@ +// This file verifies external conversions are owned once by their generated +// receiver package and retain every reflected package reference before freeze. +package service + +import ( + "bytes" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + nestedalpha "goa.design/goa/v3/codegen/service/testdata/nested-alpha" + nestedbeta "goa.design/goa/v3/codegen/service/testdata/nested-beta" + nestedouter "goa.design/goa/v3/codegen/service/testdata/nested-outer" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestExternalConversionsBelongToGeneratedReceiverPackage catches conversion +// files duplicated by two services that reference one relocated receiver. It +// also compiles two same-named reflected children from distinct Go packages. +func TestExternalConversionsBelongToGeneratedReceiverPackage(t *testing.T) { + root := externalConversionContractRoot(t) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + + files, err := Files(plan) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + require.Len(t, filesAtPath(files, conversionPath), 1) + conversion := renderSingleFileAtPath(t, files, conversionPath) + require.NotContains(t, conversion, "goa.design/goa/v3/codegen/service/testdata/a-nested-alpha") + require.Contains(t, conversion, "nestedalpha.Child") + require.NotContains(t, conversion, "nestedalpha2.Child") + compileGeneratedServiceFiles(t, files) +} + +// TestExternalConversionPlanIgnoresLaterTypeMapMutation proves linked output +// is byte-for-byte determined by facts retained in NewPlan. +func TestExternalConversionPlanIgnoresLaterTypeMapMutation(t *testing.T) { + baseline := retainedServicePlanForPackage(t, externalConversionContractRoot(t)) + baselineFiles, err := Files(baseline) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + before := renderSingleFileAtPath(t, baselineFiles, conversionPath) + + root := externalConversionContractRoot(t) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + originalServiceName := root.Services[0].Name + root.Services[0].Name = "MutatedService" + for _, mapping := range append(root.Conversions, root.Creations...) { + mapping.User.Attribute().AddMeta("struct:pkg:path", "mutated/types") + if object := expr.AsObject(mapping.User); object != nil && len(*object) > 0 { + (*object)[0].Attribute.AddMeta( + "struct:field:type", + "mutated.Value", + "mutated.local/value", + "mutated", + ) + } + mapping.User.Rename("Mutated" + mapping.User.Name()) + mapping.User = nil + mapping.External = struct{}{} + } + root.Conversions = nil + root.Creations = nil + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + require.Equal(t, "generated.local/gen/alpha", plan.Services().ServiceImport("generated.local", originalServiceName).Path) + afterFiles, err := Files(plan) + require.NoError(t, err) + after := renderSingleFileAtPath(t, afterFiles, conversionPath) + require.Equal(t, before, after) + compileGeneratedServiceFiles(t, afterFiles) +} + +// TestExternalConversionOperationsHaveCanonicalOrder catches convert.go output +// that follows TypeMap traversal rather than stable receiver identities. +func TestExternalConversionOperationsHaveCanonicalOrder(t *testing.T) { + forward := externalConversionContractRoot(t) + reverse := externalConversionContractRoot(t) + slices.Reverse(reverse.Conversions) + slices.Reverse(reverse.Creations) + forwardPlan := retainedServicePlanForPackage(t, forward) + reversePlan := retainedServicePlanForPackage(t, reverse) + forwardFiles, err := Files(forwardPlan) + require.NoError(t, err) + reverseFiles, err := Files(reversePlan) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + require.Equal( + t, + renderSingleFileAtPath(t, forwardFiles, conversionPath), + renderSingleFileAtPath(t, reverseFiles, conversionPath), + ) +} + +// TestExternalConversionReachabilityCoversEveryServiceValue catches mappings +// omitted when a type is reachable only through a stream or error contract. +func TestExternalConversionReachabilityCoversEveryServiceValue(t *testing.T) { + tests := map[string]func(expr.UserType){ + "streaming payload": func(mapped expr.UserType) { + dsl.Service("Reach", func() { + dsl.Method("Use", func() { + dsl.StreamingPayload(mapped) + dsl.Result(dsl.String) + }) + }) + }, + "mixed streaming result": func(mapped expr.UserType) { + dsl.Service("Reach", func() { + dsl.Method("Use", func() { + dsl.Result(dsl.String) + dsl.StreamingResult(mapped) + }) + }) + }, + "service error": func(mapped expr.UserType) { + dsl.Service("Reach", func() { + dsl.Error("failed", mapped) + dsl.Method("Use", func() {}) + }) + }, + "method error": func(mapped expr.UserType) { + dsl.Service("Reach", func() { + dsl.Method("Use", func() { + dsl.Error("failed", mapped) + }) + }) + }, + } + for name, use := range tests { + t.Run(name, func(t *testing.T) { + root := codegen.RunDSL(t, func() { + mapped := dsl.Type("Mapped", func() { + dsl.ConvertTo(nestedalpha.Child{}) + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + use(mapped) + }) + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "reach", "convert.go") + require.Len(t, filesAtPath(files, conversionPath), 1) + compileGeneratedServiceFiles(t, files) + }) + } +} + +// TestExternalConversionsAggregateAcrossRoots catches root-local conversion +// files that target one generated package and change with root order. +func TestExternalConversionsAggregateAcrossRoots(t *testing.T) { + forwardPlans := convertedRootPlans(t, false) + forwardFiles, err := Files(forwardPlans...) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + require.Len(t, filesAtPath(forwardFiles, conversionPath), 1) + forward := renderSingleFileAtPath(t, forwardFiles, conversionPath) + + reversePlans := convertedRootPlans(t, true) + reverseFiles, err := Files(reversePlans...) + require.NoError(t, err) + require.Len(t, filesAtPath(reverseFiles, conversionPath), 1) + require.Equal(t, forward, renderSingleFileAtPath(t, reverseFiles, conversionPath)) + require.Contains(t, forward, "func (t *AlphaMapped) ConvertToChild()") + require.Contains(t, forward, "func (t *BetaMapped) ConvertToChild()") + compileGeneratedServiceFiles(t, forwardFiles) +} + +// TestExternalConversionsShareReceiverMethodNamesAcrossRoots catches method +// names assigned independently by roots that contribute operations for the +// same canonical receiver declaration. +func TestExternalConversionsShareReceiverMethodNamesAcrossRoots(t *testing.T) { + forwardPlans := sharedConvertedReceiverPlans(t, false) + forwardFiles, err := Files(forwardPlans...) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + require.Len(t, filesAtPath(forwardFiles, conversionPath), 1) + forward := renderSingleFileAtPath(t, forwardFiles, conversionPath) + + reversePlans := sharedConvertedReceiverPlans(t, true) + reverseFiles, err := Files(reversePlans...) + require.NoError(t, err) + require.Len(t, filesAtPath(reverseFiles, conversionPath), 1) + require.Equal(t, forward, renderSingleFileAtPath(t, reverseFiles, conversionPath)) + require.Contains(t, forward, "func (t *SharedMapped) ConvertToChild()") + require.Contains(t, forward, "func (t *SharedMapped) ConvertToChild2()") + compileGeneratedServiceFiles(t, forwardFiles) +} + +// TestNewPlansRejectDuplicateExternalConversionsAcrossRoots proves the batch +// boundary rejects one exact receiver operation instead of inventing X2. +func TestNewPlansRejectDuplicateExternalConversionsAcrossRoots(t *testing.T) { + var shared expr.UserType + first := codegen.RunDSL(t, func() { + shared = dsl.Type("SharedMapped", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.ConvertTo(nestedalpha.Child{}) + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("Alpha", func() { + dsl.Method("Use", func() { dsl.Payload(shared) }) + }) + }) + second := codegen.RunDSL(t, func() { + dsl.Service("Beta", func() { + dsl.Method("Use", func() { dsl.Payload(shared) }) + }) + }) + second.Conversions = append(second.Conversions, &expr.TypeMap{ + User: shared, + External: nestedalpha.Child{}, + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{first, second}) + _, err := NewPlans( + generation, + PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "duplicate external conversion") +} + +// externalConversionContractRoot builds one relocated receiver referenced by +// two services and mapped in both conversion directions. +func externalConversionContractRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return codegen.RunDSL(t, func() { + alpha := dsl.Type("AlphaChild", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + beta := dsl.Type("BetaChild", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + envelope := dsl.Type("Envelope", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.ConvertTo(nestedouter.Envelope{}) + dsl.CreateFrom(nestedouter.Envelope{}) + dsl.Attribute("alpha", alpha) + dsl.Attribute("beta", beta) + dsl.Required("alpha", "beta") + }) + for _, service := range []string{"Alpha", "Beta"} { + dsl.Service(service, func() { + dsl.Method("Read", func() { + dsl.Payload(envelope) + }) + }) + } + }) +} + +// convertedRootPlans creates two roots whose distinct converted receivers are +// relocated into one generated package, in either discovery order. +func convertedRootPlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + roots := []*expr.RootExpr{ + convertedReceiverRoot(t, "Alpha", nestedalpha.Child{}), + convertedReceiverRoot(t, "Beta", nestedbeta.Child{}), + } + if reverse { + slices.Reverse(roots) + } + evaluated := make([]eval.Root, len(roots)) + for index, root := range roots { + evaluated[index] = root + } + generation := mustTestGeneration(t, "generated.local/gen", evaluated) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +// sharedConvertedReceiverPlans creates two roots that contribute distinct +// same-named external mappings for one exact relocated receiver declaration. +func sharedConvertedReceiverPlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + var shared expr.UserType + first := codegen.RunDSL(t, func() { + shared = dsl.Type("SharedMapped", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.ConvertTo(nestedalpha.Child{}) + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("Alpha", func() { + dsl.Method("Use", func() { + dsl.Payload(shared) + }) + }) + }) + second := codegen.RunDSL(t, func() { + dsl.Service("Beta", func() { + dsl.Method("Use", func() { + dsl.Payload(shared) + }) + }) + }) + second.Conversions = append(second.Conversions, &expr.TypeMap{ + User: shared, + External: nestedbeta.Child{}, + }) + roots := []*expr.RootExpr{first, second} + if reverse { + slices.Reverse(roots) + } + evaluated := make([]eval.Root, len(roots)) + for index, root := range roots { + evaluated[index] = root + } + generation := mustTestGeneration(t, "generated.local/gen", evaluated) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +// convertedReceiverRoot builds one relocated receiver mapping for the +// multi-root conversion aggregation contract. +func convertedReceiverRoot(t *testing.T, service string, external any) *expr.RootExpr { + t.Helper() + return codegen.RunDSL(t, func() { + mapped := dsl.Type(service+"Mapped", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.ConvertTo(external) + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service(service, func() { + dsl.Method("Use", func() { + dsl.Payload(mapped) + }) + }) + }) +} + +// filesAtPath returns every generated file that targets path. +func filesAtPath(files []*codegen.File, path string) []*codegen.File { + var matches []*codegen.File + for _, file := range files { + if file.Path == path { + matches = append(matches, file) + } + } + return matches +} + +// renderSingleFileAtPath renders the unique file targeting path. +func renderSingleFileAtPath(t *testing.T, files []*codegen.File, path string) string { + t.Helper() + matches := filesAtPath(files, path) + require.Len(t, matches, 1) + var rendered bytes.Buffer + for _, section := range matches[0].SectionTemplates { + require.NoError(t, section.Write(&rendered)) + } + return rendered.String() +} diff --git a/codegen/service/convert.go b/codegen/service/convert.go index b9f3305079..feb3d4bea2 100644 --- a/codegen/service/convert.go +++ b/codegen/service/convert.go @@ -1,3 +1,7 @@ +// This file generates ConvertTo and CreateFrom functions for service types +// mapped to external Go structs. Service-side names come from the completed +// package records, including nested types placed in packages by design +// metadata. package service import ( @@ -28,278 +32,45 @@ type convertData struct { Code string } -// ConvertFiles returns multiple files containing conversion and creation functions, -// grouped by target package as specified by struct:pkg:path metadata. -func ConvertFiles(root *expr.RootExpr, service *expr.ServiceExpr, services *ServicesData) ([]*codegen.File, error) { - // Filter conversion and creation functions that are relevant for this service - svc := services.Get(service.Name) - conversions := relevantTypeMaps(root.Conversions, service, svc) - creations := relevantTypeMaps(root.Creations, service, svc) - - if len(conversions) == 0 && len(creations) == 0 { - return nil, nil - } - - // Group conversions and creations by target package path - allPaths := make(map[string]struct{}) - conversionsByPath := groupByConvertPath(conversions, service, allPaths) - creationsByPath := groupByConvertPath(creations, service, allPaths) - - // Generate a file for each path - var files []*codegen.File - for path := range allPaths { - file, err := generateConvertFileForPath( - path, - conversionsByPath[path], - creationsByPath[path], - service, - svc, - ) - if err != nil { - return nil, err - } - if file != nil { - files = append(files, file) - } - } - - return files, nil -} - -// relevantTypeMaps filters the type maps whose user type is a method payload, -// a method result, or a user type of the given service. The returned slice -// drives which ConvertTo/CreateFrom functions ConvertFiles generates. -func relevantTypeMaps(maps []*expr.TypeMap, service *expr.ServiceExpr, svc *Data) []*expr.TypeMap { - var relevant []*expr.TypeMap - for _, c := range maps { - if typeMapMatchesService(c, service, svc) { - relevant = append(relevant, c) - } - } - return relevant -} - -// typeMapMatchesService reports whether the type map's user type is used by -// the service as a method payload, a method result, or a service user type. -func typeMapMatchesService(c *expr.TypeMap, service *expr.ServiceExpr, svc *Data) bool { - for _, m := range service.Methods { - if ut, ok := m.Payload.Type.(expr.UserType); ok && ut.Name() == c.User.Name() { - return true - } - if ut, ok := m.Result.Type.(expr.UserType); ok && ut.Name() == c.User.Name() { - return true - } - } - for _, t := range svc.userTypes { - if c.User.Name() == t.Name { - return true - } - } - return false -} - -// groupByConvertPath groups the type maps by the convert.go file path derived -// from their user type location, defaulting to the service package. It -// records every path in paths so the caller can iterate the union of -// conversion and creation paths. -func groupByConvertPath(maps []*expr.TypeMap, service *expr.ServiceExpr, paths map[string]struct{}) map[string][]*expr.TypeMap { - byPath := make(map[string][]*expr.TypeMap) - for _, c := range maps { - var path string - if loc := codegen.UserTypeLocation(c.User); loc != nil { - path = filepath.Join(codegen.Gendir, filepath.Dir(loc.FilePath), "convert.go") - } else { - path = filepath.Join(codegen.Gendir, codegen.SnakeCase(service.Name), "convert.go") - } - byPath[path] = append(byPath[path], c) - paths[path] = struct{}{} - } - return byPath -} - -// generateConvertFileForPath generates a single convert.go file for the given path -// containing the specified conversions and creations -func generateConvertFileForPath( - convertPath string, - conversions []*expr.TypeMap, - creations []*expr.TypeMap, - service *expr.ServiceExpr, - svc *Data, -) (*codegen.File, error) { - if len(conversions) == 0 && len(creations) == 0 { - return nil, nil - } - - // Determine package name from path - var convertPkgName string - if len(conversions) > 0 { - if loc := codegen.UserTypeLocation(conversions[0].User); loc != nil { - convertPkgName = loc.PackageName() - } else { - convertPkgName = svc.PkgName - } - } else if len(creations) > 0 { - if loc := codegen.UserTypeLocation(creations[0].User); loc != nil { - convertPkgName = loc.PackageName() - } else { - convertPkgName = svc.PkgName - } - } - - // Retrieve external packages info - ppm := make(map[string]string) - for _, c := range conversions { - pkgImport, alias, err := getExternalTypeInfo(c.External) - if err != nil { - return nil, err +// convertFiles formats the package-owned external conversion files aggregated +// from every linked root plan. It does not inspect design mappings or allocate +// generated names. +func convertFiles(conversions []*externalConversionFileFacts) []*codegen.File { + files := make([]*codegen.File, len(conversions)) + for index, retained := range conversions { + sections := []*codegen.SectionTemplate{ + codegen.Header( + "External type conversion functions", + codegen.Goify(path.Base(retained.owner.ImportPath()), false), + retained.imports.specs, + ), + } + for _, operation := range retained.operations { + name, source := "convert-to", serviceTemplates.Read(convertT) + if operation.direction == externalCreateFrom { + name, source = "create-from", serviceTemplates.Read(createT) + } + sections = append(sections, &codegen.SectionTemplate{ + Name: name, + Source: source, + Data: operation.data, + }) + } + for _, operation := range retained.operations { + for _, helper := range operation.helpers { + sections = append(sections, &codegen.SectionTemplate{ + Name: "convert-create-helper", + Source: serviceTemplates.Read(transformHelperT), + Data: helper, + }) + } } - ppm[pkgImport] = alias - } - for _, c := range creations { - pkgImport, alias, err := getExternalTypeInfo(c.External) - if err != nil { - return nil, err + files[index] = &codegen.File{ + Path: filepath.Join(retained.owner.OutputDirectory(), "convert.go"), + SectionTemplates: sections, } - ppm[pkgImport] = alias - } - pkgs := make([]*codegen.ImportSpec, 0, len(ppm)+2) - for pp, alias := range ppm { - pkgs = append(pkgs, &codegen.ImportSpec{Name: alias, Path: pp}) } - - // Build header section - pkgs = append(pkgs, &codegen.ImportSpec{Path: "context"}, codegen.GoaImport("")) - sections := []*codegen.SectionTemplate{ - codegen.Header(service.Name+" service type conversion functions", convertPkgName, pkgs), - } - - var ( - names = map[string]struct{}{} - transFuncs []*codegen.TransformFunctionData - ) - - // Build conversion sections if any - for _, c := range conversions { - var dt expr.DataType - if err := buildDesignType(&dt, reflect.TypeOf(c.External), c.User); err != nil { - return nil, err - } - t := reflect.TypeOf(c.External) - tgtPkg := t.String() - if idx := strings.Index(tgtPkg, "."); idx != -1 { - tgtPkg = tgtPkg[:idx] - } - - // Use the correct source context based on where the conversion file will be generated - var srcCtx *codegen.AttributeContext - if loc := codegen.UserTypeLocation(c.User); loc != nil { - // Create a context for the custom package with empty default package to avoid qualification - srcScope := codegen.NewNameScope() - // Register the user type in this scope - this will ensure proper type references - srcScope.GoTypeName(&expr.AttributeExpr{Type: c.User}) - // Use conversion context so types in the same package are not qualified - srcCtx = codegen.NewAttributeContextForConversion(false, false, true, convertPkgName, srcScope) - } else { - srcCtx = typeContext(svc.Scope) - } - tgtCtx := codegen.NewAttributeContext(false, false, false, tgtPkg, codegen.NewNameScope()) - srcAtt := &expr.AttributeExpr{Type: c.User} - tgtAtt := &expr.AttributeExpr{Type: dt} - tgtAtt.AddMeta("struct:type:name", dt.Name()) // Used by transformer to generate the correct type name. - code, tf, err := codegen.GoTransform( - srcAtt, tgtAtt, - "t", "v", srcCtx, tgtCtx, "transform", true) - if err != nil { - return nil, err - } - transFuncs = codegen.AppendHelpers(transFuncs, tf) - base := "ConvertTo" + t.Name() - name := uniquify(base, names) - ref := t.String() - if expr.IsObject(c.User) { - ref = "*" + ref - } - data := convertData{ - Name: name, - ReceiverTypeRef: srcCtx.Scope.Ref(srcAtt, ""), - TypeName: t.Name(), - TypeRef: ref, - Code: code, - } - sections = append(sections, &codegen.SectionTemplate{ - Name: "convert-to", - Source: serviceTemplates.Read(convertT), - Data: data, - }) - } - - // Build creation sections if any - for _, c := range creations { - var dt expr.DataType - if err := buildDesignType(&dt, reflect.TypeOf(c.External), c.User); err != nil { - return nil, err - } - t := reflect.TypeOf(c.External) - srcPkg := t.String() - if idx := strings.Index(srcPkg, "."); idx != -1 { - srcPkg = srcPkg[:idx] - } - srcCtx := codegen.NewAttributeContext(false, false, false, srcPkg, codegen.NewNameScope()) - - // Use the correct target context based on where the conversion file will be generated - var tgtCtx *codegen.AttributeContext - if loc := codegen.UserTypeLocation(c.User); loc != nil { - // Create a context for the custom package with empty default package to avoid qualification - tgtScope := codegen.NewNameScope() - // Register the user type in this scope - this will ensure proper type references - tgtScope.GoTypeName(&expr.AttributeExpr{Type: c.User}) - // Use conversion context so types in the same package are not qualified - tgtCtx = codegen.NewAttributeContextForConversion(false, false, true, convertPkgName, tgtScope) - } else { - tgtCtx = typeContext(svc.Scope) - } - tgtAtt := &expr.AttributeExpr{Type: c.User} - code, tf, err := codegen.GoTransform( - &expr.AttributeExpr{Type: dt}, tgtAtt, - "v", "temp", srcCtx, tgtCtx, "transform", true) - if err != nil { - return nil, err - } - transFuncs = codegen.AppendHelpers(transFuncs, tf) - base := "CreateFrom" + t.Name() - name := uniquify(base, names) - ref := t.String() - if expr.IsObject(c.User) { - ref = "*" + ref - } - data := convertData{ - Name: name, - ReceiverTypeRef: tgtCtx.Scope.Ref(tgtAtt, ""), - TypeRef: ref, - Code: code, - } - sections = append(sections, &codegen.SectionTemplate{ - Name: "create-from", - Source: serviceTemplates.Read(createT), - Data: data, - }) - } - - // Build transformation helper functions section if any. - seen := make(map[string]struct{}) - for _, tf := range transFuncs { - if _, ok := seen[tf.Name]; ok { - continue - } - seen[tf.Name] = struct{}{} - sections = append(sections, &codegen.SectionTemplate{ - Name: "convert-create-helper", - Source: serviceTemplates.Read(transformHelperT), - Data: tf, - }) - } - - return &codegen.File{Path: convertPath, SectionTemplates: sections}, nil + return files } func commonPath(sep byte, paths ...string) string { @@ -393,12 +164,13 @@ func getPkgImport(pkg, cwd string) string { return pkg } -func getExternalTypeInfo(external any) (string, string, error) { +// getExternalReflectTypeInfo returns the source import path and authored +// package qualifier for one named reflected type. +func getExternalReflectTypeInfo(pkg reflect.Type) (string, string, error) { cwd, err := os.Getwd() if err != nil { return "", "", err } - pkg := reflect.TypeOf(external) pkgImport := getPkgImport(pkg.PkgPath(), cwd) alias := strings.Split(pkg.String(), ".")[0] return pkgImport, alias, nil @@ -422,8 +194,9 @@ func uniquify(base string, taken map[string]struct{}) string { } type dtRec struct { - path string - seen map[string]expr.DataType + path string + seen map[reflect.Type]expr.DataType + named map[expr.UserType]reflect.Type } func appendPath(r dtRec, p string) dtRec { @@ -431,6 +204,22 @@ func appendPath(r dtRec, p string) dtRec { return r } +// buildExternalDesignType returns the reflected design graph and the exact Go +// type behind every named node that may require a package-qualified reference. +func buildExternalDesignType(t reflect.Type, ref expr.DataType) (expr.DataType, map[expr.UserType]reflect.Type, error) { + named := make(map[expr.UserType]reflect.Type) + rec := dtRec{ + path: "", + seen: make(map[reflect.Type]expr.DataType), + named: named, + } + var dataType expr.DataType + if err := buildDesignType(&dataType, t, ref, rec); err != nil { + return nil, nil, err + } + return dataType, named, nil +} + // buildDesignType builds a user type that represents the given external type. // ref is the user type the data type being built is converted to or created // from. It's used to compute the non-generated type field names and can be nil @@ -447,13 +236,14 @@ func buildDesignType(dt *expr.DataType, t reflect.Type, ref expr.DataType, recs var rec dtRec if recs != nil { rec = recs[0] - if s, ok := rec.seen[t.Name()]; ok { + if s, ok := rec.seen[t]; ok { *dt = s return nil } } else { rec.path = "" - rec.seen = make(map[string]expr.DataType) + rec.seen = make(map[reflect.Type]expr.DataType) + rec.named = make(map[expr.UserType]reflect.Type) } switch t.Kind() { @@ -526,22 +316,26 @@ func buildDesignType(dt *expr.DataType, t reflect.Type, ref expr.DataType, recs oref = expr.AsObject(ref) } - // Build list of fields that should not be ignored. + // Keep only fields represented by the matching design object. External + // structs may contain additional fields, but generated transforms neither + // read nor write them and therefore must not reserve their package imports. var fields []reflect.StructField for i := 0; i < t.NumField(); i++ { f := t.FieldByIndex([]int{i}) atn, _ := attributeName(oref, f.Name) if oref != nil { - if at := oref.Attribute(atn); at != nil { - if m := at.Meta["struct:field:external"]; len(m) > 0 { - if m[0] == "-" { - continue - } + at := oref.Attribute(atn) + if at == nil { + continue + } + if m := at.Meta["struct:field:external"]; len(m) > 0 { + if m[0] == "-" { + continue } - if m := at.Meta["struct.field.external"]; len(m) > 0 { // Deprecated syntax. Only present for backward compatibility. - if m[0] == "-" { - continue - } + } + if m := at.Meta["struct.field.external"]; len(m) > 0 { // Deprecated syntax. Only present for backward compatibility. + if m[0] == "-" { + continue } } } @@ -556,7 +350,8 @@ func buildDesignType(dt *expr.DataType, t reflect.Type, ref expr.DataType, recs UID: t.PkgPath() + "#" + t.Name(), } *dt = ut - rec.seen[t.Name()] = ut + rec.seen[t] = ut + rec.named[ut] = t var required []string for i, f := range fields { recf := appendPath(rec, "."+f.Name) diff --git a/codegen/service/convert_test.go b/codegen/service/convert_test.go index 0dcf39c0db..e7f915a42e 100644 --- a/codegen/service/convert_test.go +++ b/codegen/service/convert_test.go @@ -1,3 +1,5 @@ +// This file verifies service conversion paths, generated helper declarations, +// and the exact package names used by both definitions and references. package service import ( @@ -13,6 +15,8 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen/service/testdata" + aliasd "goa.design/goa/v3/codegen/service/testdata/alias-external" + "goa.design/goa/v3/codegen/service/testdata/external" "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" ) @@ -318,37 +322,138 @@ func TestConvertFiles(t *testing.T) { "gen/models/convert.go": 3, // header + convert-to + create-from sections }, }, + { + "noncanonical-location-uses-owned-package", + func() { + filter := dsl.Type("FilterConfig", func() { + dsl.Meta("struct:pkg:path", "domain/../types") + dsl.CreateFrom(testdata.TestFilterConfig{}) + dsl.ConvertTo(testdata.TestFilterConfig{}) + dsl.Attribute("name", dsl.String) + dsl.Attribute("enabled", dsl.Boolean) + dsl.Attribute("value", dsl.Int) + dsl.Required("name", "enabled", "value") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { dsl.Payload(filter) }) + }) + }, + map[string]int{ + "gen/types/convert.go": 3, + }, + }, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := runDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) + retained, err := externalConversionFiles([]*Plan{plan}) + require.NoError(t, err) + files := convertFiles(retained) - for _, svc := range root.Services { - files, err := ConvertFiles(root, svc, services) - require.NoError(t, err) + // Check expected number of files + require.Equal(t, len(c.ExpectedFiles), len(files)) - // Check expected number of files - require.Equal(t, len(c.ExpectedFiles), len(files)) - - // Verify each expected file - for expectedPath, expectedSections := range c.ExpectedFiles { - found := false - // Normalize expected path for cross-platform compatibility - normalizedExpected := filepath.FromSlash(expectedPath) - for _, file := range files { - if strings.HasSuffix(file.Path, normalizedExpected) { - found = true - require.Equal(t, expectedSections, len(file.SectionTemplates)) - // First section should be header - require.Equal(t, "source-header", file.SectionTemplates[0].Name) - break - } + // Verify each expected file + for expectedPath, expectedSections := range c.ExpectedFiles { + found := false + // Normalize expected path for cross-platform compatibility + normalizedExpected := filepath.FromSlash(expectedPath) + for _, file := range files { + if strings.HasSuffix(file.Path, normalizedExpected) { + found = true + require.Equal(t, expectedSections, len(file.SectionTemplates)) + // First section should be header + require.Equal(t, "source-header", file.SectionTemplates[0].Name) + break } - require.True(t, found, "Expected file %s not found", expectedPath) } + require.True(t, found, "Expected file %s not found", expectedPath) + } + }) + } +} + +// TestConversionPlanSharesHelperDeclarations proves recursive call edges and +// emitted helper definitions use the same package declaration retained by the +// external conversion operation. +func TestConversionPlanSharesHelperDeclarations(t *testing.T) { + root := runDSL(t, func() { + recursive := dsl.Type("Recursive", func() { + dsl.ConvertTo(objRecursiveT{}) + dsl.CreateFrom(objRecursiveT{}) + dsl.Attribute("Foo", dsl.String) + dsl.Attribute("Bar", dsl.Int) + dsl.Attribute("Goo", dsl.Float32) + dsl.Attribute("Goo2", dsl.UInt) + dsl.Attribute("Rec", "Recursive") + dsl.Required("Foo", "Bar", "Goo", "Goo2") + }) + dsl.Service("RecursiveService", func() { + dsl.Method("Read", func() { + dsl.Payload(recursive) + }) + }) + }) + plan := mustServicePlan(t, root) + files := plan.facts.externalConversions + require.Len(t, files, 1) + require.Len(t, files[0].operations, 2) + for _, operation := range files[0].operations { + planned := operation.plan.Helpers() + require.NotEmpty(t, planned) + require.Len(t, operation.helpers, len(planned)) + for index := range planned { + require.Equal(t, planned[index].ID, operation.helpers[index].ID) + require.Same(t, planned[index].Declaration, operation.helpers[index].Declaration) + } + } +} + +// TestConversionMethodNamesUseReceiverNamespaces verifies different receiver +// types may use the same method spelling while collisions on one receiver are +// resolved in stable external-package order. +func TestConversionMethodNamesUseReceiverNamespaces(t *testing.T) { + root := runDSL(t, func() { + foo := dsl.Type("Foo", func() { + dsl.ConvertTo(external.ConvertModel{}) + dsl.Attribute("Foo", dsl.String) + }) + bar := dsl.Type("Bar", func() { + dsl.ConvertTo(aliasd.ConvertModel{}) + dsl.Attribute("Bar", dsl.String) + }) + empty := dsl.Type("Empty", func() { + dsl.ConvertTo(external.ConvertModel{}) + dsl.ConvertTo(aliasd.ConvertModel{}) + }) + dsl.Service("Values", func() { + for _, method := range []struct { + name string + payload expr.UserType + }{ + {"Foo", foo}, + {"Bar", bar}, + {"Empty", empty}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.payload) + }) } }) + }) + plan := mustServicePlan(t, root) + var operations []*externalConversionFacts + for _, file := range plan.facts.externalConversions { + operations = append(operations, file.operations...) + } + names := make(map[string]string) + for _, operation := range operations { + names[operation.receiverType.Name()+":"+operation.externalPath] = operation.methodName } + require.Equal(t, "ConvertToConvertModel", names["Foo:goa.design/goa/v3/codegen/service/testdata/external"]) + require.Equal(t, "ConvertToConvertModel", names["Bar:goa.design/goa/v3/codegen/service/testdata/alias-external"]) + require.Equal(t, "ConvertToConvertModel", names["Empty:goa.design/goa/v3/codegen/service/testdata/alias-external"]) + require.Equal(t, "ConvertToConvertModel2", names["Empty:goa.design/goa/v3/codegen/service/testdata/external"]) } diff --git a/codegen/service/declaration_resolver.go b/codegen/service/declaration_resolver.go new file mode 100644 index 0000000000..8174d5e816 --- /dev/null +++ b/codegen/service/declaration_resolver.go @@ -0,0 +1,310 @@ +// This file writes service type definitions and references using the Go names +// chosen for each generated package. A type with an explicit package location +// uses that import path; a child type without one stays in its enclosing type's +// package. +package service + +import ( + "fmt" + "path" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // declarationResolver writes service fields and type references from the + // package declarations recorded by Plan. + declarationResolver struct { + generation *codegen.Generation + aliases *importAliases + serviceName string + currentPath string + outputPath string + derived map[expr.UserType]codegen.DerivedTypeID + validators map[validatorKey]*codegen.NameDeclaration + view bool + } +) + +// newServiceResolver starts in the assigned service package and qualifies type +// references for the package that will contain the generated file. +func newServiceResolver(generation *codegen.Generation, aliases *importAliases, serviceName, servicePath, outputPath string) *declarationResolver { + return &declarationResolver{ + generation: generation, + aliases: aliases, + serviceName: serviceName, + currentPath: servicePath, + outputPath: outputPath, + } +} + +// newViewResolver starts in the assigned views package and associates each +// generated view type with the service result type from which it was built. +func newViewResolver(generation *codegen.Generation, aliases *importAliases, serviceName, viewsPath string, derived map[expr.UserType]codegen.DerivedTypeID) *declarationResolver { + return &declarationResolver{ + generation: generation, + aliases: aliases, + serviceName: serviceName, + currentPath: viewsPath, + outputPath: viewsPath, + derived: derived, + view: true, + } +} + +// Name returns the generated Go type name for att. The resolver's current +// import path chooses the package containing the declaration; the textual pkg +// argument does not. +func (r *declarationResolver) Name(att *expr.AttributeExpr, _ string, ptr, useDefault bool) string { + switch actual := att.Type.(type) { + case expr.Primitive: + if custom, spec := codegen.GetMetaType(att); custom != "" { + if spec == nil { + return custom + } + _, typeName, qualified := strings.Cut(custom, ".") + if !qualified { + return custom + } + return r.aliases.name(r.outputPath, spec.Path) + "." + typeName + } + return codegen.GoNativeTypeName(actual) + case *expr.Array: + return "[]" + r.Ref(actual.ElemType, "") + case *expr.Map: + return fmt.Sprintf("map[%s]%s", r.Ref(actual.KeyType, ""), r.Ref(actual.ElemType, "")) + case *expr.Object: + return r.Def(att, ptr, useDefault) + case expr.UserType: + if actual == expr.Empty { + return "struct {}" + } + if expr.IsErrorResult(actual) { + return "goa.ServiceError" + } + owner := r.owner(att) + declaration := r.userType(owner, actual) + return r.qualify(owner, declaration.Name()) + case *expr.Union: + owner := r.owner(att) + declaration, err := r.generation.Package(owner).Union(actual) + if err != nil { + panic(fmt.Sprintf("resolve union %q for service %q in package %q: %v", actual.Name(), r.serviceName, owner, err)) + } + return r.qualify(owner, declaration.Name()) + case expr.CompositeExpr: + return r.Name(actual.Attribute(), "", ptr, useDefault) + default: + panic(fmt.Sprintf("resolve service type %T for service %q", actual, r.serviceName)) + } +} + +// Def returns the Go definition for att while resolving every nested named +// declaration through its actual generated package. +func (r *declarationResolver) Def(att *expr.AttributeExpr, ptr, useDefault bool) string { + switch actual := att.Type.(type) { + case expr.Primitive: + return r.Name(att, "", ptr, useDefault) + case *expr.Array: + definition := r.Enter(actual.ElemType).(*declarationResolver).Def(actual.ElemType, ptr, useDefault) + if expr.IsObject(actual.ElemType.Type) { + definition = "*" + definition + } + return "[]" + definition + case *expr.Map: + key := r.Enter(actual.KeyType).(*declarationResolver).Def(actual.KeyType, ptr, useDefault) + if expr.IsObject(actual.KeyType.Type) { + key = "*" + key + } + value := r.Enter(actual.ElemType).(*declarationResolver).Def(actual.ElemType, ptr, useDefault) + if expr.IsObject(actual.ElemType.Type) { + value = "*" + value + } + return fmt.Sprintf("map[%s]%s", key, value) + case *expr.Object: + lines := []string{"struct {"} + for _, field := range *actual { + fieldResolver := r.Enter(field.Attribute).(*declarationResolver) + definition := fieldResolver.Def(field.Attribute, ptr, useDefault) + if serviceFieldIsPointer(att, field.Name, ptr, useDefault) { + definition = "*" + definition + } + var description string + if field.Attribute.Description != "" { + description = codegen.Comment(field.Attribute.Description) + "\n\t" + } + lines = append(lines, fmt.Sprintf( + "\t%s%s %s%s", + description, + codegen.GoifyAtt(field.Attribute, field.Name, true), + definition, + codegen.AttributeTagsWithName(att, field.Name, field.Attribute), + )) + } + return strings.Join(append(lines, "}"), "\n") + case expr.UserType, *expr.Union: + return r.Name(att, "", ptr, useDefault) + case expr.CompositeExpr: + return r.Def(actual.Attribute(), ptr, useDefault) + default: + panic(fmt.Sprintf("define service type %T for service %q", actual, r.serviceName)) + } +} + +// Ref returns the generated Go reference for att. +func (r *declarationResolver) Ref(att *expr.AttributeExpr, pkg string) string { + name := r.Name(att, pkg, false, false) + if _, ok := att.Type.(*expr.Object); ok { + return name + } + if expr.IsObject(att.Type) || expr.IsUnion(att.Type) { + return "*" + name + } + return name +} + +// Field returns the generated Go field name for one service attribute. +func (*declarationResolver) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { + return codegen.GoifyAtt(att, name, firstUpper) +} + +// Package returns the qualifier for att relative to the file being rendered. +func (r *declarationResolver) Package(att *expr.AttributeExpr) string { + owner := r.currentPath + if att != nil { + owner = r.owner(att) + } + if owner == r.outputPath { + return "" + } + return r.aliases.name(r.outputPath, owner) +} + +// Enter returns a resolver whose current package owns att and its unlocated +// nested declarations. +func (r *declarationResolver) Enter(att *expr.AttributeExpr) codegen.Attributor { + owner := r.owner(att) + if owner == r.currentPath { + return r + } + entered := *r + entered.currentPath = owner + return &entered +} + +// withOutputPackage returns a resolver that keeps declarations in the current +// package but qualifies references for a file emitted in packagePath. +func (r *declarationResolver) withOutputPackage(packagePath string) *declarationResolver { + if packagePath == r.outputPath { + return r + } + output := *r + output.outputPath = packagePath + return &output +} + +// bindDerived returns a resolver that associates a render-only expression +// origin with one declaration planned from its exact source type. +func (r *declarationResolver) bindDerived(origin expr.UserType, identity codegen.DerivedTypeID) *declarationResolver { + bound := *r + bound.derived = make(map[expr.UserType]codegen.DerivedTypeID, len(r.derived)+1) + for existing, existingIdentity := range r.derived { + bound.derived[existing] = existingIdentity + } + bound.derived[origin.Origin()] = identity + return &bound +} + +// withValidators returns a resolver that maps each child validation call to the +// Go function declaration submitted during service planning. +func (r *declarationResolver) withValidators(validators map[validatorKey]*codegen.NameDeclaration) *declarationResolver { + bound := *r + bound.validators = validators + return &bound +} + +// IsSumType reports that service unions use generated values that hold one branch. +func (*declarationResolver) IsSumType() bool { + return true +} + +// ValidatorCall returns a call to the validation function submitted for att +// and view before Generation.Freeze chose the function's Go name. +func (r *declarationResolver) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + declaration := r.validatorDeclaration(att, view) + return fmt.Sprintf("%s(%s)", r.qualify(r.owner(att), declaration.Name()), target) +} + +// validatorDeclaration returns the NameDeclaration recorded for att and the +// selected view. The default view uses the same empty key as its call sites. +func (r *declarationResolver) validatorDeclaration(att *expr.AttributeExpr, view string) *codegen.NameDeclaration { + userType, ok := att.Type.(expr.UserType) + if !ok { + panic(fmt.Sprintf("resolve validator for non-user type %T", att.Type)) + } + owner := r.owner(att) + declaration := r.userType(owner, userType) + validator := r.validators[validatorKey{declaration: declaration, view: canonicalValidatorView(view)}] + if validator == nil { + panic(fmt.Sprintf( + "validator for type %q view %q was not retained in generated package %q", + userType.Name(), view, owner, + )) + } + return validator +} + +// Scope returns the name set for the resolver's current generated package. +func (r *declarationResolver) Scope() *codegen.NameScope { + return r.generation.Package(r.currentPath).Scope() +} + +// owner returns the import path of the package containing att. View-specific +// result copies stay in the views package after Goa removes their original +// struct:pkg:path metadata. +func (r *declarationResolver) owner(att *expr.AttributeExpr) string { + if r.view { + return r.currentPath + } + if location := codegen.UserTypeLocation(att.Type); location != nil { + return path.Join(r.generation.GenPkg(), location.RelImportPath) + } + return r.currentPath +} + +// userType selects an exact, generated union branch, or rebuilt view record. +func (r *declarationResolver) userType(owner string, userType expr.UserType) *codegen.TypeDeclaration { + generatedPackage := r.generation.Package(owner) + if identity, ok := r.derived[userType.Origin()]; ok { + declaration, err := generatedPackage.DerivedType(identity) + if err != nil { + panic(fmt.Sprintf("resolve derived type %q for service %q in package %q: %v", userType.Name(), r.serviceName, owner, err)) + } + return declaration + } + declaration, err := generatedPackage.Type(userType) + if err != nil { + panic(fmt.Sprintf("resolve user type %q for service %q in package %q: %v", userType.Name(), r.serviceName, owner, err)) + } + return declaration +} + +// qualify adds the owning package name when the current output file is in a +// different generated package. +func (r *declarationResolver) qualify(owner, name string) string { + if owner == r.outputPath { + return name + } + return r.aliases.name(r.outputPath, owner) + "." + name +} + +// serviceFieldIsPointer applies Goa's service-struct pointer rules to one field +// definition. +func serviceFieldIsPointer(parent *expr.AttributeExpr, name string, pointer, useDefault bool) bool { + field := expr.AsObject(parent.Type).Attribute(name) + return expr.IsObject(field.Type) || + parent.IsPrimitivePointer(name, useDefault) || + pointer && expr.IsPrimitive(field.Type) && field.Type.Kind() != expr.AnyKind && field.Type.Kind() != expr.BytesKind +} diff --git a/codegen/service/declaration_resolver_test.go b/codegen/service/declaration_resolver_test.go new file mode 100644 index 0000000000..235e97f35e --- /dev/null +++ b/codegen/service/declaration_resolver_test.go @@ -0,0 +1,254 @@ +// This file verifies that service transformations resolve every named type +// through the frozen package catalog as recursion crosses explicit package +// locations. +package service + +import ( + "path" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestDeclarationResolverTransformsRelocatedUnionBranches verifies both +// conversion directions use the frozen generated alias in the owning package. +func TestDeclarationResolverTransformsRelocatedUnionBranches(t *testing.T) { + service := &expr.ServiceExpr{Name: "Convert"} + generatedBranch := resolverUserType("ValueText", expr.String) + union := &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: generatedBranch}}, + }, + } + relocated := resolverUserType("Record", &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: union}}, + }) + relocated.Attribute().AddMeta("struct:pkg:path", "types") + + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + _, err := types.DeclareUserType(relocated) + require.NoError(t, err) + _, err = types.DeclareUserType(resolverUserType("ValueText", expr.Int)) + require.NoError(t, err) + _, err = types.DeclareUnion(union) + require.NoError(t, err) + branchDeclaration, err := types.DeclareUnionBranchType(union, "text", generatedBranch) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.Equal(t, "ValueText2", branchDeclaration.Name()) + + externalBranch := resolverUserType("ExternalValueText", expr.String) + externalUnion := &expr.Union{ + TypeName: "ExternalValue", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: externalBranch}}, + }, + } + external := resolverUserType("ExternalRecord", &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: externalUnion}}, + }) + + relocatedAttribute := &expr.AttributeExpr{Type: relocated} + externalAttribute := &expr.AttributeExpr{Type: external} + resolver := newServiceResolver( + generation, + aliasesForTest(t, "generated.local/gen/types"), + service.Name, + servicePackagePath(generation.GenPkg(), service), + "generated.local/gen/types", + ) + relocatedContext := declarationContext(resolver.Enter(relocatedAttribute), false) + externalContext := codegen.NewAttributeContext(false, false, true, "external", codegen.NewNameScope()) + + _, _, err = codegen.GoTransform( + relocatedAttribute, + externalAttribute, + "record", + "externalRecord", + relocatedContext, + externalContext, + "convert", + true, + ) + require.NoError(t, err) + + toRelocated, toRelocatedHelpers, err := codegen.GoTransform( + externalAttribute, + relocatedAttribute, + "externalRecord", + "record", + externalContext, + relocatedContext, + "create", + true, + ) + require.NoError(t, err) + require.Contains(t, transformSource(toRelocated, toRelocatedHelpers), "ValueText2") +} + +// TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType +// verifies errors and interceptor fields use their actual package owner. +func TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType(t *testing.T) { + service := &expr.ServiceExpr{Name: "Collisions"} + local := resolverUserType("Fault", expr.String) + relocated := resolverUserType("fault", expr.String) + relocated.Attribute().AddMeta("struct:pkg:path", "errors") + container := resolverUserType("Container", &expr.Object{ + {Name: "fault", Attribute: &expr.AttributeExpr{Type: relocated}}, + }) + container.Attribute().AddMeta("struct:pkg:path", "types") + + generation := mustTestGeneration(t, "generated.local/gen", nil) + servicePackage := mustClaimTestPackage(t, generation, servicePackagePath(generation.GenPkg(), service)) + localDeclaration, err := servicePackage.DeclareUserType(local) + require.NoError(t, err) + errorConstructor := codegen.NewPreferredName( + codegen.NameFunction, + "MakeFault", + codegen.ExportedName, + serviceNameOrder{role: serviceErrorConstructorNameRole, subject: "fault"}, + ) + require.NoError(t, servicePackage.DeclareName(errorConstructor)) + errorsPackage := mustClaimTestPackage(t, generation, "generated.local/gen/errors") + _, err = errorsPackage.DeclareUserType(relocated) + require.NoError(t, err) + typesPackage := mustClaimTestPackage(t, generation, "generated.local/gen/types") + _, err = typesPackage.DeclareUserType(container) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + resolver := newServiceResolver( + generation, + aliasesForTest( + t, + servicePackagePath(generation.GenPkg(), service), + "generated.local/gen/errors", + "generated.local/gen/types", + ), + service.Name, + servicePackagePath(generation.GenPkg(), service), + servicePackagePath(generation.GenPkg(), service), + ) + require.Equal(t, "Fault", localDeclaration.Name()) + require.Equal(t, "Fault", resolver.Ref(&expr.AttributeExpr{Type: local}, "")) +} + +// TestDeclarationResolverPanicsWhenPlanOmittedType verifies render analysis +// fails immediately instead of allocating a missing declaration. +func TestDeclarationResolverPanicsWhenPlanOmittedType(t *testing.T) { + service := &expr.ServiceExpr{Name: "Missing"} + generation := mustTestGeneration(t, "generated.local/gen", nil) + mustClaimTestPackage(t, generation, servicePackagePath(generation.GenPkg(), service)) + require.NoError(t, generation.Freeze()) + resolver := newServiceResolver( + generation, + aliasesForTest(t, servicePackagePath(generation.GenPkg(), service)), + service.Name, + servicePackagePath(generation.GenPkg(), service), + servicePackagePath(generation.GenPkg(), service), + ) + missing := resolverUserType("Missing", expr.String) + require.PanicsWithValue( + t, + "resolve user type \"Missing\" for service \"Missing\" in package \"generated.local/gen/missing\": user type \"Missing\" has no declaration in generated package \"generated.local/gen/missing\"", + func() { + resolver.Name(&expr.AttributeExpr{Type: missing}, "", false, true) + }, + ) +} + +// TestServicesDataServiceAttributorUsesFrozenPackageDeclarations verifies +// transport generators can consume the same local, relocated, and nested +// declaration records used by service rendering without accessing resolver +// state. +func TestServicesDataServiceAttributorUsesFrozenPackageDeclarations(t *testing.T) { + var record expr.UserType + root := codegen.RunDSL(t, func() { + record = dsl.Type("Record", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + dsl.Attribute("external", dsl.String, func() { + dsl.Meta("struct:field:type", "custom.Value", "example.com/custom", "custom") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(record) + }) + }) + }) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + consumer, err := generation.ClaimOutputPackage("example.com/consumer", "consumer") + require.NoError(t, err) + require.NoError(t, consumer.ReserveGeneratedImport(codegen.NewImport("types", "goa.design/goa/example/types"))) + require.NoError(t, consumer.DeclareImport(codegen.NewImport("custom", "example.com/custom"))) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + services := plan.Services() + external := services.ServiceAttributor("Values", "example.com/consumer") + recordAttribute := &expr.AttributeExpr{Type: record} + recordResolver := external.Enter(recordAttribute) + value := expr.AsObject(record.Attribute().Type).Attribute("Value") + externalValue := expr.AsObject(record.Attribute().Type).Attribute("external") + + require.Equal(t, "*types.Record", external.Ref(recordAttribute, "")) + require.Equal(t, "*types.Value", recordResolver.Ref(value, "")) + require.Equal(t, "custom.Value", recordResolver.Ref(externalValue, "")) + + typesPackage := "goa.design/goa/example/types" + local := services.ServiceAttributor("Values", typesPackage).Enter(recordAttribute) + require.Equal(t, "*Record", local.Ref(recordAttribute, "")) + require.Equal(t, "*Value", local.Ref(value, "")) +} + +// aliasesForTest builds the same frozen full-path qualifier table used by +// service analysis for the package paths exercised by a focused resolver test. +func aliasesForTest(t *testing.T, paths ...string) *importAliases { + t.Helper() + generation := mustTestGeneration(t, "generated.local/gen", nil) + packages := make([]*codegen.GeneratedPackage, len(paths)) + for index, importPath := range paths { + packages[index] = mustClaimTestPackage(t, generation, importPath) + } + for _, pkg := range packages { + for _, importPath := range paths { + if importPath != pkg.ImportPath() { + require.NoError(t, pkg.DeclareImport(codegen.NewImport(codegen.Goify(path.Base(importPath), false), importPath))) + } + } + } + require.NoError(t, generation.Freeze()) + return &importAliases{generation: generation} +} + +// resolverUserType constructs one exact declaration for resolver tests. +func resolverUserType(name string, dataType expr.DataType) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: dataType}, + TypeName: name, + UID: "resolver-test#" + name, + } +} + +// transformSource combines an inline transformation with every recursive +// helper so tests can assert the complete code emitted for one conversion. +func transformSource(code string, helpers []*codegen.TransformFunctionData) string { + var source strings.Builder + source.WriteString(code) + for _, helper := range helpers { + source.WriteString(helper.Code) + } + return source.String() +} diff --git a/codegen/service/endpoint.go b/codegen/service/endpoint.go index ca26a860f5..ed191643ec 100644 --- a/codegen/service/endpoint.go +++ b/codegen/service/endpoint.go @@ -1,3 +1,5 @@ +// This file renders one service's endpoint API and derives type imports only +// from the methods emitted into that endpoint file. package service import ( @@ -6,23 +8,42 @@ import ( "strings" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) type ( // EndpointsData contains the data necessary to render the // service endpoints struct template. EndpointsData struct { + // EndpointsDeclaration is the exact package-level endpoint collection. + EndpointsDeclaration *codegen.NameDeclaration + // NewEndpointsDeclaration is the exact endpoint constructor. + NewEndpointsDeclaration *codegen.NameDeclaration + // ClientDeclaration is the exact package-level client. + ClientDeclaration *codegen.NameDeclaration + // NewClientDeclaration is the exact client constructor. + NewClientDeclaration *codegen.NameDeclaration + // ServiceDeclaration is the exact service interface. + ServiceDeclaration *codegen.NameDeclaration + // ServerInterceptorsDeclaration is the exact server interceptor interface. + ServerInterceptorsDeclaration *codegen.NameDeclaration + // ClientInterceptorsDeclaration is the exact client interceptor interface. + ClientInterceptorsDeclaration *codegen.NameDeclaration + // VarName is the generated endpoint collection name kept for existing plugins. + // + // Deprecated: Use EndpointsDeclaration.Name() after planning. + VarName string + // ClientVarName is the generated client name kept for existing plugins. + // + // Deprecated: Use ClientDeclaration.Name() after planning. + ClientVarName string + // ServiceVarName is the generated service interface name kept for existing plugins. + // + // Deprecated: Use ServiceDeclaration.Name() after planning. + ServiceVarName string // Name is the service name. Name string // Description is the service description. Description string - // VarName is the endpoint struct name. - VarName string - // ClientVarName is the client struct name. - ClientVarName string - // ServiceVarName is the service interface name. - ServiceVarName string // Methods lists the endpoint struct methods. Methods []*EndpointMethodData // ClientInitArgs lists the arguments needed to instantiate the client. @@ -41,6 +62,18 @@ type ( // EndpointMethodData describes a single endpoint method. EndpointMethodData struct { *MethodData + // ClientDeclaration is the exact package-level client used as the method receiver. + ClientDeclaration *codegen.NameDeclaration + // ServiceDeclaration is the exact service interface accepted by the endpoint constructor. + ServiceDeclaration *codegen.NameDeclaration + // ClientVarName is the generated client name kept for existing plugins. + // + // Deprecated: Use ClientDeclaration.Name() after planning. + ClientVarName string + // ServiceVarName is the generated service interface name kept for existing plugins. + // + // Deprecated: Use ServiceDeclaration.Name() after planning. + ServiceVarName string // ArgName is the name of the argument used to initialize the client // struct method field. ArgName string @@ -49,27 +82,15 @@ type ( // // It is only set when HasMixedResults is true. StreamArgName string - // ClientVarName is the corresponding client struct field name. - ClientVarName string - // ServiceName is the name of the owner service. + // ServiceName is the name of the service that declares this method. ServiceName string - // ServiceVarName is the name of the owner service Go interface. - ServiceVarName string } ) -const ( - // endpointsStructName is the name of the generated endpoints data - // structure. - endpointsStructName = "Endpoints" - - // serviceInterfaceName is the name of the generated service interface. - serviceInterfaceName = "Service" -) - -// EndpointFile returns the endpoint file for the given service. -func EndpointFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { - svc := services.Get(service.Name) +// endpointFile renders endpoints from the service data copied into plan. +func endpointFile(plan *Plan, facts *serviceFacts) *codegen.File { + services := plan.Services() + svc := services.Get(facts.name) svcName := svc.PathName path := filepath.Join(codegen.Gendir, svcName, "endpoints.go") data := endpointData(svc) @@ -77,15 +98,7 @@ func EndpointFile(genpkg string, service *expr.ServiceExpr, services *ServicesDa sections []*codegen.SectionTemplate ) { - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "io"}, - {Path: "fmt"}, - codegen.GoaImport(""), - codegen.GoaImport("security"), - {Path: genpkg + "/" + svcName + "/" + "views", Name: svc.ViewsPkg}, - } - header := codegen.Header(service.Name+" endpoints", svc.PkgName, imports) + header := codegen.Header(facts.name+" endpoints", svc.PkgName, facts.imports.endpoint.specs) def := &codegen.SectionTemplate{ Name: "endpoints-struct", Source: serviceTemplates.Read(serviceEndpointsT), @@ -94,18 +107,11 @@ func EndpointFile(genpkg string, service *expr.ServiceExpr, services *ServicesDa sections = []*codegen.SectionTemplate{header, def} for _, m := range data.Methods { if m.ServerStream != nil { - // Generate endpoint input struct for streaming methods - // For JSON-RPC WebSocket with StreamingResult: generate struct (needed for stream handle) - // For JSON-RPC WebSocket without StreamingResult (client streaming only): no struct needed - // For JSON-RPC SSE: always generate struct (methods have stream params) - // For HTTP/gRPC: always generate endpoint input struct - if !m.IsJSONRPCWebSocket || m.ServerStream.EndpointStruct != "" { - sections = append(sections, &codegen.SectionTemplate{ - Name: "endpoint-input-struct", - Source: serviceTemplates.Read(serviceEndpointStreamStructT), - Data: m, - }) - } + sections = append(sections, &codegen.SectionTemplate{ + Name: "endpoint-input-struct", + Source: serviceTemplates.Read(serviceEndpointStreamStructT), + Data: m, + }) } if m.SkipRequestBodyEncodeDecode { sections = append(sections, &codegen.SectionTemplate{ @@ -157,36 +163,41 @@ func endpointData(svc *Data) *EndpointsData { names = append(names, streamArgName) } methods[i] = &EndpointMethodData{ - MethodData: m, - ArgName: argName, - StreamArgName: streamArgName, - ServiceName: svc.Name, - ServiceVarName: serviceInterfaceName, - ClientVarName: clientStructName, + MethodData: m, + ClientDeclaration: svc.ClientDeclaration, + ServiceDeclaration: svc.ServiceDeclaration, + ClientVarName: svc.ClientDeclaration.Name(), + ServiceVarName: svc.ServiceDeclaration.Name(), + ArgName: argName, + StreamArgName: streamArgName, + ServiceName: svc.Name, } } - desc := fmt.Sprintf("%s wraps the %q service endpoints.", endpointsStructName, svc.Name) + desc := fmt.Sprintf("%s wraps the %q service endpoints.", svc.EndpointsDeclaration.Name(), svc.Name) return &EndpointsData{ - Name: svc.Name, - Description: desc, - VarName: endpointsStructName, - ClientVarName: clientStructName, - ServiceVarName: serviceInterfaceName, - ClientInitArgs: strings.Join(names, ", "), - Methods: methods, - Schemes: svc.Schemes, - HasServerInterceptors: len(svc.ServerInterceptors) > 0, - HasClientInterceptors: len(svc.ClientInterceptors) > 0, + EndpointsDeclaration: svc.EndpointsDeclaration, + NewEndpointsDeclaration: svc.NewEndpointsDeclaration, + ClientDeclaration: svc.ClientDeclaration, + NewClientDeclaration: svc.NewClientDeclaration, + ServiceDeclaration: svc.ServiceDeclaration, + ServerInterceptorsDeclaration: svc.ServerInterceptorsDeclaration, + ClientInterceptorsDeclaration: svc.ClientInterceptorsDeclaration, + VarName: svc.EndpointsDeclaration.Name(), + ClientVarName: svc.ClientDeclaration.Name(), + ServiceVarName: svc.ServiceDeclaration.Name(), + Name: svc.Name, + Description: desc, + ClientInitArgs: strings.Join(names, ", "), + Methods: methods, + Schemes: svc.Schemes, + HasServerInterceptors: len(svc.ServerInterceptors) > 0, + HasClientInterceptors: len(svc.ClientInterceptors) > 0, } } func payloadVar(e *EndpointMethodData) string { if e.ServerStream != nil { - if e.ServerStream.EndpointStruct != "" { - return "ep.Payload" - } - // JSON-RPC WebSocket has no payload for server streaming - return "" + return "ep.Payload" } if e.SkipRequestBodyEncodeDecode { return "ep.Payload" diff --git a/codegen/service/endpoint_test.go b/codegen/service/endpoint_test.go index b42c12b235..0bc3e05d1d 100644 --- a/codegen/service/endpoint_test.go +++ b/codegen/service/endpoint_test.go @@ -40,9 +40,9 @@ func TestEndpoint(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := EndpointFile("goa.design/goa/example", root.Services[0], services) + fs := endpointFile(plan, plan.facts.services[0]) require.NotNil(t, fs) buf := new(bytes.Buffer) for _, s := range fs.SectionTemplates[1:] { diff --git a/codegen/service/example_generator_test.go b/codegen/service/example_generator_test.go new file mode 100644 index 0000000000..e9a8e61162 --- /dev/null +++ b/codegen/service/example_generator_test.go @@ -0,0 +1,74 @@ +// This file verifies that service analysis retains the exact mutable example +// generator owned by its generation run. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestServicesDataRetainsRunExampleGenerator(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(dsl.String) + }) + }) + }) + root.API.RandomizerFactory = expr.NewDeterministicRandomizerFactory() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + examples := expr.NewExampleGenerator(root.API.RandomizerFactory) + plan, err := NewPlan(root, generation, examples) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + services := plan.Services() + require.NoError(t, err) + attribute := &expr.AttributeExpr{Type: expr.String} + method := root.Services[0].Methods[0] + owner := expr.MethodResultExampleIdentity(method) + require.Equal(t, "abc123", services.Example(attribute, owner)) + require.Equal(t, "abc123", services.FieldExample(attribute, attribute, "value", owner)) +} + +func TestRepeatedServiceReadsKeepAnonymousExamplesStable(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Primitive", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + }) + dsl.Method("Array", func() { + dsl.Payload(dsl.ArrayOf(dsl.String)) + dsl.Result(dsl.ArrayOf(dsl.Int)) + }) + dsl.Method("Map", func() { + dsl.Payload(dsl.MapOf(dsl.String, dsl.Int)) + dsl.Result(dsl.MapOf(dsl.Int, dsl.String)) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + examples := expr.NewExampleGenerator(root.API.RandomizerFactory) + plan, err := NewPlan(root, generation, examples) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + first := plan.Services() + second := plan.Services() + require.Len(t, first.Get("Values").Methods, 3) + require.Len(t, second.Get("Values").Methods, 3) + for index, firstMethod := range first.Get("Values").Methods { + secondMethod := second.Get("Values").Methods[index] + require.Equal(t, firstMethod.PayloadEx, secondMethod.PayloadEx, firstMethod.Name+" payload") + require.Equal(t, firstMethod.ResultEx, secondMethod.ResultEx, firstMethod.Name+" result") + } +} diff --git a/codegen/service/example_interceptors.go b/codegen/service/example_interceptors.go index 4580cfdc90..c469e3b204 100644 --- a/codegen/service/example_interceptors.go +++ b/codegen/service/example_interceptors.go @@ -1,85 +1,104 @@ +// This file renders starter interceptor implementations that depend only on +// the service package and interceptor metadata, not service type packages. package service import ( "fmt" - "os" "path" "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -// ExampleInterceptorsFiles returns the files for the example server and client interceptors. -func ExampleInterceptorsFiles(genpkg string, r *expr.RootExpr, services *ServicesData) []*codegen.File { +type ( + // exampleInterceptorData contains the generated type and constructor names + // plus service metadata rendered by one starter interceptor implementation. + exampleInterceptorData struct { + // ServiceName is the design service name described by the comments. + ServiceName string + // ServicePkg is the generated service package qualifier. + ServicePkg string + // StructDeclaration is the starter interceptor implementation type. + StructDeclaration *codegen.NameDeclaration + // ConstructorDeclaration creates StructDeclaration. + ConstructorDeclaration *codegen.NameDeclaration + // Interceptors contains the interceptor methods implemented by the type. + Interceptors []*InterceptorData + } +) + +// ExampleInterceptorsFiles returns starter server and client interceptor files +// for every service copied into plan. +func ExampleInterceptorsFiles(plan *Plan) []*codegen.File { var fw []*codegen.File - for _, svc := range r.Services { - if f := exampleInterceptorsFile(genpkg, svc, services); f != nil { + for _, facts := range plan.facts.services { + if f := exampleInterceptorsFile(plan, facts); f != nil { fw = append(fw, f...) } } return fw } -// exampleInterceptorsFile returns the example interceptors for the given service. -func exampleInterceptorsFile(genpkg string, svc *expr.ServiceExpr, services *ServicesData) []*codegen.File { - sdata := services.Get(svc.Name) - data := map[string]any{ - "ServiceName": sdata.Name, - "StructName": sdata.StructName, - "PkgName": "interceptors", - "ServerInterceptors": sdata.ServerInterceptors, - "ClientInterceptors": sdata.ClientInterceptors, +// exampleInterceptorsFile renders starter interceptors from one service copied +// into plan. +func exampleInterceptorsFile(plan *Plan, facts *serviceFacts) []*codegen.File { + if len(facts.serverInterceptors) == 0 && len(facts.clientInterceptors) == 0 { + return nil } + genpkg := plan.generation.GenPkg() + services := plan.Services() + sdata := services.Get(facts.name) + servicePath := path.Join(genpkg, sdata.PathName) + servicePkg := services.aliases.name(path.Join(path.Dir(genpkg), "interceptors"), servicePath) var files []*codegen.File - // Generate server interceptor if needed and file doesn't exist + // Generate the server interceptor starter when the service uses one. if len(sdata.ServerInterceptors) > 0 { + data := &exampleInterceptorData{ + ServiceName: sdata.Name, + ServicePkg: servicePkg, + StructDeclaration: facts.exampleServerStruct, + ConstructorDeclaration: facts.exampleServerConstructor, + Interceptors: sdata.ServerInterceptors, + } serverPath := filepath.Join("interceptors", sdata.PathName+"_server.go") - if _, err := os.Stat(serverPath); os.IsNotExist(err) { - files = append(files, &codegen.File{ - Path: serverPath, - SectionTemplates: []*codegen.SectionTemplate{ - codegen.Header(fmt.Sprintf("%s example server interceptors", sdata.Name), "interceptors", []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - {Path: "goa.design/clue/log"}, - codegen.GoaImport(""), - {Path: path.Join(genpkg, sdata.PathName), Name: sdata.PkgName}, - }), - { - Name: "example-server-interceptor", - Source: serviceTemplates.Read(exampleServerInterceptorT), - Data: data, - }, + files = append(files, &codegen.File{ + Path: serverPath, + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header(fmt.Sprintf("%s example server interceptors", sdata.Name), "interceptors", facts.imports.exampleServerInterceptors.specs), + { + Name: "example-server-interceptor", + Source: serviceTemplates.Read(exampleServerInterceptorT), + Data: data, }, - }) - } + }, + SkipExist: true, + }) } - // Generate client interceptor if needed and file doesn't exist + // Generate the client interceptor starter when the service uses one. if len(sdata.ClientInterceptors) > 0 { + data := &exampleInterceptorData{ + ServiceName: sdata.Name, + ServicePkg: servicePkg, + StructDeclaration: facts.exampleClientStruct, + ConstructorDeclaration: facts.exampleClientConstructor, + Interceptors: sdata.ClientInterceptors, + } clientPath := filepath.Join("interceptors", sdata.PathName+"_client.go") - if _, err := os.Stat(clientPath); os.IsNotExist(err) { - files = append(files, &codegen.File{ - Path: clientPath, - SectionTemplates: []*codegen.SectionTemplate{ - codegen.Header(fmt.Sprintf("%s example client interceptors", sdata.Name), "interceptors", []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - {Path: "goa.design/clue/log"}, - codegen.GoaImport(""), - {Path: path.Join(genpkg, sdata.PathName), Name: sdata.PkgName}, - }), - { - Name: "example-client-interceptor", - Source: serviceTemplates.Read(exampleClientInterceptorT), - Data: data, - }, + files = append(files, &codegen.File{ + Path: clientPath, + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header(fmt.Sprintf("%s example client interceptors", sdata.Name), "interceptors", facts.imports.exampleClientInterceptors.specs), + { + Name: "example-client-interceptor", + Source: serviceTemplates.Read(exampleClientInterceptorT), + Data: data, }, - }) - } + }, + SkipExist: true, + }) } return files diff --git a/codegen/service/example_interceptors_test.go b/codegen/service/example_interceptors_test.go index c4e69999f5..f534a5d0a6 100644 --- a/codegen/service/example_interceptors_test.go +++ b/codegen/service/example_interceptors_test.go @@ -1,3 +1,5 @@ +// This file verifies the starter server and client interceptor files generated +// from API, service, and method interceptor declarations. package service import ( @@ -10,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" ) @@ -84,12 +87,13 @@ func TestExampleInterceptorsFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { // Run DSL root := runDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.NotNil(t, root) // Generate files - fs := ExampleInterceptorsFiles("", root, services) + fs := ExampleInterceptorsFiles(plan) require.Len(t, fs, len(c.ExpectedFiles)) + assertExampleInterceptorDeclarations(t, plan, fs) // Verify file paths paths := make([]string, len(fs)) @@ -121,3 +125,37 @@ func TestExampleInterceptorsFiles(t *testing.T) { }) } } + +func TestServerInterceptorConstructorIsAvailableToExampleMain(t *testing.T) { + root := runDSL(t, testdata.ServerInterceptorExampleDSL) + plan := mustServicePlan(t, root) + facts := plan.facts.services[0] + + require.Same( + t, + facts.exampleServerConstructor, + plan.Services().Get(facts.name).ExampleServerInterceptorsConstructorDeclaration, + ) +} + +// assertExampleInterceptorDeclarations verifies that starter definitions and +// constructor bodies use the exact declarations retained by the service plan. +func assertExampleInterceptorDeclarations(t *testing.T, plan *Plan, files []*codegen.File) { + t.Helper() + retained := make(map[*codegen.NameDeclaration]*codegen.NameDeclaration) + for _, facts := range plan.facts.services { + if facts.exampleServerStruct != nil { + retained[facts.exampleServerStruct] = facts.exampleServerConstructor + } + if facts.exampleClientStruct != nil { + retained[facts.exampleClientStruct] = facts.exampleClientConstructor + } + } + for _, file := range files { + data, ok := file.SectionTemplates[1].Data.(*exampleInterceptorData) + require.True(t, ok) + constructor, ok := retained[data.StructDeclaration] + require.True(t, ok, "starter interceptor struct was not retained by the plan") + require.Same(t, constructor, data.ConstructorDeclaration) + } +} diff --git a/codegen/service/example_svc.go b/codegen/service/example_svc.go index 1253fc5c0b..eea87b0e42 100644 --- a/codegen/service/example_svc.go +++ b/codegen/service/example_svc.go @@ -1,9 +1,9 @@ +// This file renders starter service implementations and imports only the +// generated types referenced by each implementation's service methods. package service import ( - "os" "path" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" @@ -30,79 +30,65 @@ type ( // StreamInterface is the stream interface in the service package used // by the endpoint implementation. StreamInterface string + // ExampleStructDeclaration is the starter implementation receiver. + ExampleStructDeclaration *codegen.NameDeclaration } -) -// ExampleServiceFiles returns a basic service implementation for every -// service expression. -func ExampleServiceFiles(genpkg string, root *expr.RootExpr, services *ServicesData) []*codegen.File { - // determine the unique API package name different from the service names - scope := codegen.NewNameScope() - for _, svc := range root.Services { - s := services.Get(svc.Name) - if s == nil { - panic("unknown service, " + svc.Name) // bug - } - scope.Unique(s.PkgName) + // exampleServiceData separates the generated service package declaration + // name from the qualifier used by this example file. + exampleServiceData struct { + *Data + // ServicePkg is the import name used for the generated service package in + // this example file. + ServicePkg string } - apipkg := scope.Unique(strings.ToLower(codegen.Goify(root.API.Name, false)), "api") +) +// ExampleServiceFiles returns a basic implementation for every service +// copied into plan. +func ExampleServiceFiles(plan *Plan) []*codegen.File { var fw []*codegen.File - for _, svc := range root.Services { - if f := exampleServiceFile(genpkg, root, svc, services, apipkg); f != nil { + for _, facts := range plan.facts.services { + if f := exampleServiceFile(plan, facts, plan.facts.examplePackageName); f != nil { fw = append(fw, f) } } return fw } -// exampleServiceFile returns a basic implementation of the given service. -func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, services *ServicesData, apipkg string) *codegen.File { - data := services.Get(svc.Name) +// exampleServiceFile renders a basic implementation from one service copied +// into plan. +func exampleServiceFile(plan *Plan, facts *serviceFacts, apipkg string) *codegen.File { + genpkg := plan.generation.GenPkg() + services := plan.Services() + data := services.Get(facts.name) svcName := data.PathName + servicePath := path.Join(genpkg, svcName) + servicePkg := services.aliases.name(path.Dir(genpkg), servicePath) + renderData := &exampleServiceData{Data: data, ServicePkg: servicePkg} fpath := svcName + ".go" - if _, err := os.Stat(fpath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } - specs := []*codegen.ImportSpec{ - {Path: "io"}, - {Path: "context"}, - {Path: "fmt"}, - {Path: "strings"}, - {Path: path.Join(genpkg, svcName), Name: data.PkgName}, - {Path: "goa.design/clue/log"}, - {Path: "goa.design/goa/v3/security"}, - } sections := []*codegen.SectionTemplate{ - codegen.Header("", apipkg, specs), + codegen.Header("", apipkg, facts.imports.exampleService.specs), { Name: "basic-service-struct", Source: serviceTemplates.Read(exampleServiceStructT), - Data: data, + Data: renderData, }, { Name: "basic-service-init", Source: serviceTemplates.Read(exampleServiceInitT), - Data: data, + Data: renderData, }, } if len(data.Schemes) > 0 { sections = append(sections, &codegen.SectionTemplate{ Name: "security-authfuncs", Source: serviceTemplates.Read(exampleSecurityAuthfuncsT), - Data: data, + Data: renderData, }) } - for _, m := range svc.Methods { - sections = append(sections, basicEndpointSection(m, data)) - } - - // Add HandleStream method for JSON-RPC WebSocket services (not SSE) - if hasJSONRPCWebSocket(data) { - sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-handle-stream", - Source: serviceTemplates.Read(jsonrpcHandleStreamT), - Data: data, - }) + outputPath := path.Dir(genpkg) + for _, method := range facts.orderedMethods { + sections = append(sections, basicEndpointSection(method, data, outputPath, services.aliases, servicePkg)) } return &codegen.File{ @@ -112,31 +98,33 @@ func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, } } -// basicEndpointSection returns a section with a basic implementation for the -// given method. -func basicEndpointSection(m *expr.MethodExpr, svcData *Data) *codegen.SectionTemplate { - md := svcData.Method(m.Name) +// basicEndpointSection returns a starter implementation whose payload and +// result references come from the method records after all generated package +// and declaration names have been chosen. +func basicEndpointSection(facts *methodFacts, svcData *Data, outputPath string, aliases *importAliases, servicePkg string) *codegen.SectionTemplate { + md := svcData.Method(facts.name) ed := &basicEndpointData{ - MethodData: md, - ServiceVarName: svcData.VarName, + MethodData: md, + ServiceVarName: svcData.VarName, + ExampleStructDeclaration: svcData.ExampleStructDeclaration, } - if m.Payload.Type != expr.Empty { - ed.PayloadFullRef = svcData.Scope.GoFullTypeRef(m.Payload, svcData.PkgName) + if facts.payload != nil && facts.payload.layout.Kind() != codegen.GoEmpty { + ed.PayloadFullRef = facts.payload.layout.Link(outputPath, retainedTypeQualifier(aliases, outputPath)).Ref() } - if m.Result.Type != expr.Empty { - ed.ResultFullName = svcData.Scope.GoFullTypeName(m.Result, svcData.PkgName) - ed.ResultFullRef = svcData.Scope.GoFullTypeRef(m.Result, svcData.PkgName) - ed.ResultIsStruct = expr.IsObject(m.Result.Type) + if facts.result != nil && facts.result.layout.Kind() != codegen.GoEmpty { + linked := facts.result.layout.Link(outputPath, retainedTypeQualifier(aliases, outputPath)) + ed.ResultFullName = linked.Name() + ed.ResultFullRef = linked.Ref() + ed.ResultIsStruct = facts.result.isObject if md.ViewedResult != nil { - view := expr.DefaultView - if v, ok := m.Result.Meta.Last(expr.ViewMetaKey); ok { - view = v + ed.ResultView = facts.viewedResult.viewName + if ed.ResultView == "" { + ed.ResultView = expr.DefaultView } - ed.ResultView = view } } if md.ServerStream != nil { - ed.StreamInterface = svcData.PkgName + "." + md.ServerStream.Interface + ed.StreamInterface = servicePkg + "." + md.ServerStreamDeclaration.Name() } return &codegen.SectionTemplate{ Name: "basic-endpoint", diff --git a/codegen/service/example_svc_test.go b/codegen/service/example_svc_test.go index 0de9c87dad..b52f13469e 100644 --- a/codegen/service/example_svc_test.go +++ b/codegen/service/example_svc_test.go @@ -1,3 +1,5 @@ +// This file verifies the starter service implementations generated from +// normalized service methods and their frozen package references. package service import ( @@ -9,6 +11,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" + "goa.design/goa/v3/codegen/testutil" ) func TestExampleServiceFiles(t *testing.T) { @@ -32,9 +35,9 @@ func TestExampleServiceFiles(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 3) - fs := ExampleServiceFiles("", root, services) + fs := ExampleServiceFiles(plan) require.Len(t, fs, 3) for _, f := range fs { require.Greater(t, len(f.SectionTemplates), 0) @@ -48,4 +51,24 @@ func TestExampleServiceFiles(t *testing.T) { }) } }) + + t.Run("mixed result methods", func(t *testing.T) { + cases := []struct { + Name string + DSL func() + Golden string + }{ + {"result and stream", testdata.MixedResultsEndpointDSL, "testdata/golden/example_service-mixed-results.go.golden"}, + {"result view and stream", testdata.MixedResultsWithViewsEndpointDSL, "testdata/golden/example_service-mixed-results-with-views.go.golden"}, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + root := codegen.RunDSL(t, c.DSL) + plan := mustServicePlan(t, root) + files := ExampleServiceFiles(plan) + require.Len(t, files, 1) + testutil.AssertGo(t, c.Golden, renderSections(t, files[0].SectionTemplates)) + }) + } + }) } diff --git a/codegen/service/generated_emission.go b/codegen/service/generated_emission.go new file mode 100644 index 0000000000..cb08564df8 --- /dev/null +++ b/codegen/service/generated_emission.go @@ -0,0 +1,187 @@ +// This file attaches service template data to types and Goa OneOf unions that +// are written outside their service package. The generation command selects +// each output package and declaration before source is written. +package service + +import ( + "fmt" + "strings" + + "goa.design/goa/v3/codegen" +) + +// generatedPackage returns the template data collected for one generated Go +// package. It creates an empty container when this is the package's first type +// or union. +func (d *ServicesData) generatedPackage(importPath string) *generatedPackageData { + owner := d.generation.Package(importPath) + if generatedPackage, ok := d.packages[owner]; ok { + return generatedPackage + } + generatedPackage := &generatedPackageData{ + types: make(map[*codegen.TypeDeclaration]*generatedTypeData), + unions: make(map[*codegen.UnionDeclaration]*UnionTypeData), + } + d.packages[owner] = generatedPackage + return generatedPackage +} + +// registerPackageData associates each selected type and union declaration with +// the template section and imports written to its generated package. +func (d *ServicesData) registerPackageData() { + for _, emission := range d.facts.generatedTypes { + section, errorSection := generatedTypeSections(emission) + generatedPackage := d.generatedPackage(emission.declaration.PackagePath()) + generatedPackage.types[emission.declaration] = &generatedTypeData{ + declaration: emission.declaration, + location: emission.location, + imports: emission.service.generatedTypeImports[emission.declaration].specs, + section: section, + error: errorSection, + } + } + for _, emission := range d.facts.generatedUnions { + generatedPackage := d.generatedPackage(emission.union.declaration.PackagePath()) + generatedPackage.unions[emission.union.declaration] = emission.union.data + generatedPackage.unionImports = appendImportSpecs( + generatedPackage.unionImports, + emission.union.imports.specs, + ) + } +} + +// generatedTypeSections returns the template sections for one selected payload, +// result, error, or user type. +func generatedTypeSections(emission *generatedTypeEmissionFacts) (*codegen.SectionTemplate, *codegen.SectionTemplate) { + if emission.method != nil { + var methodData *MethodData + for index, method := range emission.service.orderedMethods { + if method == emission.method { + methodData = emission.service.data.Methods[index] + break + } + } + switch emission.kind { + case generatedPayloadEmission: + return &codegen.SectionTemplate{Name: "service-payload", Source: serviceTemplates.Read(payloadT), Data: methodData}, nil + case generatedStreamingPayloadEmission: + return &codegen.SectionTemplate{Name: "service-streaming-payload", Source: serviceTemplates.Read(streamingPayloadT), Data: methodData}, nil + case generatedResultEmission: + return &codegen.SectionTemplate{Name: "service-result", Source: serviceTemplates.Read(resultT), Data: methodData}, nil + case generatedStreamingResultEmission: + return &codegen.SectionTemplate{ + Name: "service-streaming-result", + Source: serviceTemplates.Read(resultT), + Data: map[string]any{ + "Result": methodData.StreamingResult, + "ResultDef": methodData.StreamingResultDef, + "ResultDesc": methodData.StreamingResultDesc, + }, + }, nil + } + } + data := generatedUserTypeData(emission) + name := "service-user-type" + if emission.kind == generatedErrorTypeEmission { + name = "error-user-type" + } + section := &codegen.SectionTemplate{Name: name, Source: serviceTemplates.Read(userTypeT), Data: data} + if !emission.error { + return section, nil + } + return section, &codegen.SectionTemplate{Name: "service-error", Source: serviceTemplates.Read(errorT), Data: data} +} + +// generatedUserTypeData returns the template data for one authored type +// declaration selected for a generated package. +func generatedUserTypeData(emission *generatedTypeEmissionFacts) *UserTypeData { + candidates := emission.service.data.userTypes + if emission.kind == generatedErrorTypeEmission { + candidates = emission.service.data.errorTypes + } + for _, candidate := range candidates { + if candidate.Declaration == emission.declaration { + data := *candidate + if data.Description == "" { + data.Description = generatedUserTypeDescription(emission) + } + return &data + } + } + panic(fmt.Sprintf("generated type %q has no linked render data", emission.declaration.Name())) +} + +// generatedUserTypeDescription explains where an authored type is used. A +// nested type has no method role of its own. +func generatedUserTypeDescription(emission *generatedTypeEmissionFacts) string { + name := emission.declaration.Name() + if len(emission.uses) == 0 { + return fmt.Sprintf("%s is a named type defined in the service design.", name) + } + if len(emission.uses) == 1 { + use := emission.uses[0] + return fmt.Sprintf( + "%s is the %s type of the %s service %s method.", + name, + generatedTypeRoleNames(use.roles), + use.service, + use.method, + ) + } + var description strings.Builder + fmt.Fprintf(&description, "%s is used by these service methods:", name) + for _, use := range emission.uses { + fmt.Fprintf( + &description, + "\n- %s %s: %s", + use.service, + use.method, + generatedTypeRoleNames(use.roles), + ) + } + return description.String() +} + +// generatedTypeRoleNames joins the method fields that use one authored type. +func generatedTypeRoleNames(roles generatedTypeMethodRoles) string { + names := make([]string, 0, 4) + for _, role := range []struct { + value generatedTypeMethodRoles + name string + }{ + {generatedPayloadRole, "payload"}, + {generatedStreamingPayloadRole, "streaming payload"}, + {generatedResultRole, "result"}, + {generatedStreamingResultRole, "streaming result"}, + } { + if roles&role.value != 0 { + names = append(names, role.name) + } + } + if len(names) == 1 { + return names[0] + } + if len(names) == 2 { + return names[0] + " and " + names[1] + } + return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1] +} + +// copyGeneratedLocation copies the requested package and file so later changes +// to the design expression cannot change where the type is written. +func copyGeneratedLocation(location *codegen.Location) *codegen.Location { + if location == nil { + return nil + } + copy := *location + return © +} + +// sameGeneratedLocation compares the explicit package and file selected for a +// generated declaration. +func sameGeneratedLocation(left, right *codegen.Location) bool { + if left == nil || right == nil { + return left == right + } + return left.RelImportPath == right.RelImportPath && left.FilePath == right.FilePath +} diff --git a/codegen/service/generated_package.go b/codegen/service/generated_package.go new file mode 100644 index 0000000000..29f329bde2 --- /dev/null +++ b/codegen/service/generated_package.go @@ -0,0 +1,1140 @@ +// This file assigns service types and unions to the generated packages that +// write them. Each generated package writes each declaration once. +package service + +import ( + "fmt" + "path" + "slices" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // generatedTypeEmissionKind identifies the template that writes one type + // outside its service package. + generatedTypeEmissionKind uint8 + + // generatedTypeEmissionFacts records one type declaration written outside its + // service package and the service data used to render it. + generatedTypeEmissionFacts struct { + kind generatedTypeEmissionKind + declaration *codegen.TypeDeclaration + location *codegen.Location + root *rootFacts + service *serviceFacts + method *methodFacts + attribute *methodAttributeFacts + userType *userTypeFacts + uses []generatedTypeMethodUse + error bool + } + + // generatedTypeMethodUse records how one service method directly uses an + // authored type written outside the service package. + generatedTypeMethodUse struct { + service string + method string + roles generatedTypeMethodRoles + } + + // generatedTypeMethodRoles records the method fields whose declared type is + // the authored type being written. + generatedTypeMethodRoles uint8 + + // generatedUnionEmissionFacts records one Goa OneOf declaration and the data + // used to write it outside its service package. + generatedUnionEmissionFacts struct { + root *rootFacts + service *serviceFacts + union *unionFacts + } + + // plannedAttribute records one service field and the Go package used by child + // types that do not declare their own struct:pkg:path location. + plannedAttribute struct { + attribute *expr.AttributeExpr + service *serviceFacts + location *codegen.Location + } + + // plannedUserType identifies one user type written to one generated package. + // Extend may copy the same expression into more than one package. + plannedUserType struct { + userType expr.UserType + owner *codegen.GeneratedPackage + } + + // rootTypeSet maps compiler-created copies back to the user type declared in + // the same design. Generated Goa OneOf branch aliases are not included. + rootTypeSet struct { + byOrigin map[expr.UserType]expr.UserType + } + + // generatedPackageData stores the render data emitted into one Go package. + generatedPackageData struct { + types map[*codegen.TypeDeclaration]*generatedTypeData + unions map[*codegen.UnionDeclaration]*UnionTypeData + unionImports []*codegen.ImportSpec + } + + // generatedTypeData stores one user-type declaration placed in the file + // selected by its metadata, plus optional error behavior. + generatedTypeData struct { + declaration *codegen.TypeDeclaration + location *codegen.Location + imports []*codegen.ImportSpec + section *codegen.SectionTemplate + error *codegen.SectionTemplate + } +) + +const ( + generatedPayloadEmission generatedTypeEmissionKind = iota + 1 + generatedStreamingPayloadEmission + generatedResultEmission + generatedStreamingResultEmission + generatedUserTypeEmission + generatedErrorTypeEmission +) + +const ( + generatedPayloadRole generatedTypeMethodRoles = 1 << iota + generatedStreamingPayloadRole + generatedResultRole + generatedStreamingResultRole +) + +// collectServiceDeclarations submits every user type and Goa OneOf declaration +// reachable from root. It submits authored type names first, so generated union +// names receive a number when both request the same Go name. +func collectServiceDeclarations(facts *rootFacts, generation *codegen.Generation) error { + if !generation.HasRoot(facts.root) { + return rootMembershipError(facts.root) + } + inputs := planningInputs(facts) + rootTypes := facts.rootTypes + for _, serviceFacts := range facts.services { + // Record the service package now. File building reads its Go names after all + // generators finish submitting declarations. + if _, err := generation.ClaimPackage(serviceFacts.packagePath); err != nil { + return err + } + } + methodTypes, err := planMethodTypes(facts, generation) + if err != nil { + return err + } + + seenTypes := make(map[plannedUserType]struct{}) + for _, input := range inputs { + if err := planUserTypes(input.attribute, input.service, input.location, generation, rootTypes, methodTypes, seenTypes); err != nil { + return err + } + } + + seenTypes = make(map[plannedUserType]struct{}) + for _, input := range inputs { + if err := planUnions(input.attribute, input.service, input.location, generation, rootTypes, seenTypes); err != nil { + return err + } + } + if err := planViews(facts, generation); err != nil { + return err + } + return nil +} + +// collectGeneratedPackageEmissions selects the one service that supplies the +// definition for each type and Goa OneOf declaration shared across the designs +// in this generation command. +func collectGeneratedPackageEmissions(roots []*rootFacts) error { + types := make(map[*codegen.TypeDeclaration]*generatedTypeEmissionFacts) + unions := make(map[*codegen.UnionDeclaration]*generatedUnionEmissionFacts) + for _, root := range roots { + root.generatedTypes = nil + root.generatedUnions = nil + for _, service := range root.services { + for _, union := range service.unions { + emission := &generatedUnionEmissionFacts{root: root, service: service, union: union} + if existing := unions[union.declaration]; existing != nil { + if err := validateGeneratedUnionEmission(existing, emission); err != nil { + return err + } + if generatedUnionEmissionLess(emission, existing) { + unions[union.declaration] = emission + } + } else { + unions[union.declaration] = emission + } + } + for _, method := range service.orderedMethods { + candidates := []struct { + kind generatedTypeEmissionKind + attribute *methodAttributeFacts + }{ + {generatedPayloadEmission, method.payload}, + {generatedStreamingPayloadEmission, method.streamingPayload}, + {generatedResultEmission, method.result}, + } + if method.hasMixedResults { + candidates = append(candidates, struct { + kind generatedTypeEmissionKind + attribute *methodAttributeFacts + }{generatedStreamingResultEmission, method.streamingResult}) + } + for _, candidate := range candidates { + attribute := candidate.attribute + if attribute == nil || attribute.location == nil || !attribute.normalized || attribute.definition == nil { + continue + } + emission := &generatedTypeEmissionFacts{ + kind: candidate.kind, + declaration: attribute.layout.TypeDeclaration(), + location: copyGeneratedLocation(attribute.location), + root: root, + service: service, + method: method, + attribute: attribute, + } + if err := selectGeneratedTypeEmission(types, emission); err != nil { + return err + } + } + } + for _, userType := range service.userTypes { + if userType.location == nil { + continue + } + emission := &generatedTypeEmissionFacts{ + kind: generatedUserTypeEmission, + declaration: userType.declaration, + location: copyGeneratedLocation(userType.location), + root: root, + service: service, + userType: userType, + uses: generatedTypeMethodUses(service, userType.declaration), + } + if err := selectGeneratedTypeEmission(types, emission); err != nil { + return err + } + } + for _, errorType := range service.errorTypes { + if errorType.location == nil || errorType.serviceError { + continue + } + emission := &generatedTypeEmissionFacts{ + kind: generatedErrorTypeEmission, + declaration: errorType.declaration, + location: copyGeneratedLocation(errorType.location), + root: root, + service: service, + userType: errorType, + error: true, + } + if err := selectGeneratedTypeEmission(types, emission); err != nil { + return err + } + } + } + } + for _, emission := range types { + emission.root.generatedTypes = append(emission.root.generatedTypes, emission) + } + for _, emission := range unions { + emission.root.generatedUnions = append(emission.root.generatedUnions, emission) + } + for _, root := range roots { + sort.Slice(root.generatedTypes, func(i, j int) bool { + return generatedTypeEmissionLess(root.generatedTypes[i], root.generatedTypes[j]) + }) + sort.Slice(root.generatedUnions, func(i, j int) bool { + return generatedUnionEmissionLess(root.generatedUnions[i], root.generatedUnions[j]) + }) + } + return nil +} + +// selectGeneratedTypeEmission keeps one copy when two services would write the +// same type declaration and rejects them when their definitions differ. +func selectGeneratedTypeEmission(selected map[*codegen.TypeDeclaration]*generatedTypeEmissionFacts, candidate *generatedTypeEmissionFacts) error { + existing := selected[candidate.declaration] + if existing == nil { + selected[candidate.declaration] = candidate + return nil + } + if err := validateGeneratedTypeEmission(existing, candidate); err != nil { + return err + } + uses := mergeGeneratedTypeMethodUses(existing.uses, candidate.uses) + if generatedTypeEmissionLess(candidate, existing) { + candidate.uses = uses + selected[candidate.declaration] = candidate + } else { + existing.uses = uses + } + return nil +} + +// generatedTypeMethodUses records each method that declares the authored type +// as its payload, result, or streamed value. Types used only inside another +// type do not match these declarations. +func generatedTypeMethodUses(service *serviceFacts, declaration *codegen.TypeDeclaration) []generatedTypeMethodUse { + uses := make([]generatedTypeMethodUse, 0, len(service.orderedMethods)) + for _, method := range service.orderedMethods { + var roles generatedTypeMethodRoles + roles = addGeneratedTypeMethodRole(roles, method.payload, declaration, generatedPayloadRole) + roles = addGeneratedTypeMethodRole(roles, method.streamingPayload, declaration, generatedStreamingPayloadRole) + roles = addGeneratedTypeMethodRole(roles, method.result, declaration, generatedResultRole) + roles = addGeneratedTypeMethodRole(roles, method.streamingResult, declaration, generatedStreamingResultRole) + if roles == 0 { + continue + } + uses = append(uses, generatedTypeMethodUse{ + service: service.name, + method: method.name, + roles: roles, + }) + } + return mergeGeneratedTypeMethodUses(nil, uses) +} + +// addGeneratedTypeMethodRole records role when the method field directly uses +// declaration. +func addGeneratedTypeMethodRole(roles generatedTypeMethodRoles, attribute *methodAttributeFacts, declaration *codegen.TypeDeclaration, role generatedTypeMethodRoles) generatedTypeMethodRoles { + if attribute != nil && attribute.layout != nil && attribute.layout.TypeDeclaration() == declaration { + return roles | role + } + return roles +} + +// mergeGeneratedTypeMethodUses combines the methods found through different +// services or design roots and returns them in a stable order. +func mergeGeneratedTypeMethodUses(left, right []generatedTypeMethodUse) []generatedTypeMethodUse { + uses := append(append(make([]generatedTypeMethodUse, 0, len(left)+len(right)), left...), right...) + sort.Slice(uses, func(i, j int) bool { + if uses[i].service != uses[j].service { + return uses[i].service < uses[j].service + } + return uses[i].method < uses[j].method + }) + merged := uses[:0] + for _, use := range uses { + last := len(merged) - 1 + if last >= 0 && merged[last].service == use.service && merged[last].method == use.method { + merged[last].roles |= use.roles + continue + } + merged = append(merged, use) + } + return merged +} + +// validateGeneratedTypeEmission returns an error when two services would write +// different definitions for the same generated Go type declaration. +func validateGeneratedTypeEmission(left, right *generatedTypeEmissionFacts) error { + if left.kind != right.kind || !sameGeneratedLocation(left.location, right.location) || + !sameGeneratedTypeEmissionSource(left, right) || + !sameGeneratedTypeEmissionContent(left, right) || + !generatedTypeEmissionLayout(left).Equivalent(generatedTypeEmissionLayout(right)) || + !slices.Equal( + left.service.generatedTypeImports[left.declaration].paths, + right.service.generatedTypeImports[right.declaration].paths, + ) { + return fmt.Errorf( + "conflicting generated type emission in package %q: roles %d and %d, sources %q and %q", + left.declaration.PackagePath(), + left.kind, + right.kind, + generatedTypeEmissionName(left), + generatedTypeEmissionName(right), + ) + } + return nil +} + +// generatedTypeEmissionLayout returns the Go type definition supplied by one +// service. References from other service files do not affect which definition +// is written. +func generatedTypeEmissionLayout(emission *generatedTypeEmissionFacts) *codegen.GoTypePlan { + if emission.userType != nil { + return emission.userType.layout + } + return emission.attribute.definition +} + +// sameGeneratedTypeEmissionContent reports whether two services would write +// the same comment and error behavior for one type declaration. +func sameGeneratedTypeEmissionContent(left, right *generatedTypeEmissionFacts) bool { + if left.error != right.error { + return false + } + if left.userType != nil || right.userType != nil { + return left.userType != nil && right.userType != nil && + left.userType.name == right.userType.name && + left.userType.description == right.userType.description && + left.userType.errorName == right.userType.errorName && + left.userType.serviceError == right.userType.serviceError + } + return left.service.packagePath == right.service.packagePath && + left.method.name == right.method.name && + left.attribute.description == right.attribute.description +} + +// sameGeneratedTypeEmissionSource reports whether two candidates came from the +// same authored type. Generated aliases for the same Goa OneOf branch also +// match because the package already owns one declaration for that branch. +func sameGeneratedTypeEmissionSource(left, right *generatedTypeEmissionFacts) bool { + if generatedTypeEmissionOrigin(left) == generatedTypeEmissionOrigin(right) { + return true + } + return left.userType != nil && right.userType != nil && + !left.root.rootTypes.contains(left.userType.userType) && + !right.root.rootTypes.contains(right.userType.userType) +} + +// generatedTypeEmissionName describes the design type that caused a conflict +// while selecting one generated definition. +func generatedTypeEmissionName(emission *generatedTypeEmissionFacts) string { + origin := generatedTypeEmissionOrigin(emission) + if origin == nil { + return "" + } + return origin.Name() +} + +// validateGeneratedUnionEmission returns an error when two services would +// write the same Goa OneOf declaration in different files or with different +// fields. +func validateGeneratedUnionEmission(left, right *generatedUnionEmissionFacts) error { + if left.union.declaration != right.union.declaration || + left.union.identity != right.union.identity || + left.union.typeKey != right.union.typeKey || + left.union.valueKey != right.union.valueKey || + generatedLocationPath(left.union.location) != generatedLocationPath(right.union.location) || + !sameGeneratedUnionBranches(left.union.branches, right.union.branches) || + !slices.Equal(left.union.imports.paths, right.union.imports.paths) { + return fmt.Errorf( + "conflicting generated union emission in package %q: declarations equal=%t, keys %q/%q and %q/%q", + left.union.declaration.PackagePath(), + left.union.declaration == right.union.declaration, + left.union.typeKey, + left.union.valueKey, + right.union.typeKey, + right.union.valueKey, + ) + } + return nil +} + +// sameGeneratedUnionBranches reports whether two services would write the same +// branch fields, constructors, validation, and JSON functions for one Goa OneOf +// declaration. +func sameGeneratedUnionBranches(left, right []*unionBranchFacts) bool { + if len(left) != len(right) { + return false + } + for index := range left { + leftBranch, rightBranch := left[index], right[index] + if leftBranch.name != rightBranch.name || + leftBranch.fieldName != rightBranch.fieldName || + leftBranch.declaration != rightBranch.declaration || + !leftBranch.layout.Equivalent(rightBranch.layout) || + leftBranch.nilable != rightBranch.nilable || + leftBranch.emitPrimitiveAlias != rightBranch.emitPrimitiveAlias || + leftBranch.primitiveAliasType != rightBranch.primitiveAliasType { + return false + } + } + return true +} + +// generatedLocationPath returns the generated package selected by location. +// Union declarations always emit in unions.go, so their enclosing type's file +// name does not affect which generated package contains the union. +func generatedLocationPath(location *codegen.Location) string { + if location == nil { + return "" + } + return location.RelImportPath +} + +// generatedTypeEmissionOrigin returns the exact authored or normalized source +// whose layout defines an emitted declaration. +func generatedTypeEmissionOrigin(emission *generatedTypeEmissionFacts) expr.UserType { + if emission.userType != nil { + return emission.userType.userType.Origin() + } + if userType, ok := emission.attribute.attribute.Type.(expr.UserType); ok { + return userType.Origin() + } + return nil +} + +// generatedTypeEmissionLess orders equal definitions by service and method +// names, so walking designs in another order does not change the selected copy. +func generatedTypeEmissionLess(left, right *generatedTypeEmissionFacts) bool { + if left.declaration.PackagePath() != right.declaration.PackagePath() { + return left.declaration.PackagePath() < right.declaration.PackagePath() + } + if left.location.FilePath != right.location.FilePath { + return left.location.FilePath < right.location.FilePath + } + if left.service.packagePath != right.service.packagePath { + return left.service.packagePath < right.service.packagePath + } + leftMethod, rightMethod := "", "" + if left.method != nil { + leftMethod = left.method.name + } + if right.method != nil { + rightMethod = right.method.name + } + return leftMethod < rightMethod +} + +// generatedUnionEmissionLess orders equal Goa OneOf definitions by package and +// service names. +func generatedUnionEmissionLess(left, right *generatedUnionEmissionFacts) bool { + if left.union.declaration.PackagePath() != right.union.declaration.PackagePath() { + return left.union.declaration.PackagePath() < right.union.declaration.PackagePath() + } + return left.service.packagePath < right.service.packagePath +} + +// rootMembershipError reports an attempt to generate files from a design that +// was not supplied to this generation command. +func rootMembershipError(root *expr.RootExpr) error { + return fmt.Errorf("service root %p does not belong to the generation", root) +} + +// planMethodTypes submits the payload and result wrapper types created for raw +// object definitions. Authored user types are submitted separately and keep +// their requested names when no declaration conflicts. +func planMethodTypes(facts *rootFacts, generation *codegen.Generation) (map[expr.UserType]codegen.DerivedTypeID, error) { + planned := make(map[expr.UserType]codegen.DerivedTypeID) + for _, serviceFacts := range facts.services { + generatedPackage := generation.Package(serviceFacts.packagePath) + for _, method := range serviceFacts.methods { + attributes := []*expr.AttributeExpr{ + method.Payload, + method.StreamingPayload, + method.Result, + } + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + continue + } + identity, ok := generation.NormalizedMethodType(userType) + if !ok { + continue + } + _, derived, err := generatedPackage.DeclareMethodType(identity, userType) + if err != nil { + return nil, err + } + planned[userType.Origin()] = derived + } + } + } + return planned, nil +} + +// planningInputs returns the payloads, results, errors, and stream values that +// can write service types. It excludes design types that no service uses. +func planningInputs(facts *rootFacts) []plannedAttribute { + var inputs []plannedAttribute + for _, serviceFacts := range facts.services { + for _, serviceError := range serviceFacts.errors { + inputs = append(inputs, plannedAttribute{attribute: serviceError.AttributeExpr, service: serviceFacts}) + } + for _, method := range serviceFacts.methods { + inputs = append(inputs, + plannedAttribute{attribute: method.Payload, service: serviceFacts}, + plannedAttribute{attribute: method.StreamingPayload, service: serviceFacts}, + plannedAttribute{attribute: method.Result, service: serviceFacts}, + ) + if method.HasMixedResults() { + inputs = append(inputs, plannedAttribute{attribute: method.StreamingResult, service: serviceFacts}) + } + for _, methodError := range method.Errors { + inputs = append(inputs, plannedAttribute{attribute: methodError.AttributeExpr, service: serviceFacts}) + } + } + for _, userType := range facts.types { + services, ok := userType.Attribute().Meta["type:generate:force"] + if !ok || len(services) > 0 && !slices.Contains(services, serviceFacts.name) { + continue + } + inputs = append(inputs, plannedAttribute{ + attribute: &expr.AttributeExpr{Type: userType}, + service: serviceFacts, + }) + } + } + return inputs +} + +// planUserTypes walks attribute and submits each user type to the Go package +// selected by its own metadata or by its enclosing type. +func planUserTypes(attribute *expr.AttributeExpr, service *serviceFacts, location *codegen.Location, generation *codegen.Generation, rootTypes *rootTypeSet, methodTypes map[expr.UserType]codegen.DerivedTypeID, seen map[plannedUserType]struct{}) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + recurse := func(attribute *expr.AttributeExpr, location *codegen.Location) error { + return planUserTypes(attribute, service, location, generation, rootTypes, methodTypes, seen) + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if _, normalized := methodTypes[actual.Origin()]; normalized { + return recurse(actual.Attribute(), location) + } + declaredType := rootTypes.canonical(actual) + typeLocation := codegen.UserTypeLocation(actual) + if typeLocation == nil { + typeLocation = location + } + owner, err := claimGeneratedPackage(generation, service.packagePath, typeLocation) + if err != nil { + return err + } + key := plannedUserType{userType: declaredType, owner: owner} + if _, ok := seen[key]; ok { + return nil + } + seen[key] = struct{}{} + if _, err := owner.DeclareUserType(declaredType); err != nil { + return err + } + return recurse(actual.Attribute(), typeLocation) + case *expr.Object: + for _, named := range *actual { + if err := recurse(named.Attribute, location); err != nil { + return err + } + } + case *expr.Array: + return recurse(actual.ElemType, location) + case *expr.Map: + if err := recurse(actual.KeyType, location); err != nil { + return err + } + return recurse(actual.ElemType, location) + case *expr.Union: + for _, named := range actual.Values { + if userType, ok := generatedUnionBranch(named, rootTypes); ok { + if err := recurse(userType.Attribute(), location); err != nil { + return err + } + continue + } + if err := recurse(named.Attribute, location); err != nil { + return err + } + } + } + return nil +} + +// planUnions walks attribute after authored user type names have been submitted +// and records each Goa OneOf declaration in the package that writes it. +func planUnions(attribute *expr.AttributeExpr, service *serviceFacts, location *codegen.Location, generation *codegen.Generation, rootTypes *rootTypeSet, seen map[plannedUserType]struct{}) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + recurse := func(attribute *expr.AttributeExpr, location *codegen.Location) error { + return planUnions(attribute, service, location, generation, rootTypes, seen) + } + switch actual := attribute.Type.(type) { + case expr.UserType: + declaredType := rootTypes.canonical(actual) + typeLocation := codegen.UserTypeLocation(actual) + if typeLocation == nil { + typeLocation = location + } + owner, err := claimGeneratedPackage(generation, service.packagePath, typeLocation) + if err != nil { + return err + } + key := plannedUserType{userType: declaredType, owner: owner} + if _, ok := seen[key]; ok { + return nil + } + seen[key] = struct{}{} + return recurse(actual.Attribute(), typeLocation) + case *expr.Object: + for _, named := range sortedNamedAttributes(*actual) { + if err := recurse(named.Attribute, location); err != nil { + return err + } + } + case *expr.Array: + return recurse(actual.ElemType, location) + case *expr.Map: + if err := recurse(actual.KeyType, location); err != nil { + return err + } + return recurse(actual.ElemType, location) + case *expr.Union: + generatedPackage, err := claimGeneratedPackage(generation, service.packagePath, location) + if err != nil { + return err + } + if _, err := generatedPackage.DeclareUnion(actual); err != nil { + return err + } + for _, named := range actual.Values { + if userType, ok := generatedUnionBranch(named, rootTypes); ok { + if _, err := generatedPackage.DeclareUnionBranchType(actual, named.Name, userType); err != nil { + return err + } + if err := recurse(userType.Attribute(), location); err != nil { + return err + } + continue + } + if err := recurse(named.Attribute, location); err != nil { + return err + } + } + } + return nil +} + +// planViews builds the result types for each declared view, submits their Go +// type names, and then submits Goa OneOf types written in the views package. +func planViews(facts *rootFacts, generation *codegen.Generation) error { + for _, serviceFacts := range facts.services { + views, err := generation.ClaimPackage(serviceFacts.viewsPath) + if err != nil { + return err + } + seenProjected := make(map[expr.UserType]expr.UserType) + projectedFactsByOrigin := make(map[expr.UserType]*projectedTypeFacts) + derived := make(map[expr.UserType]codegen.DerivedTypeID) + var projectedRoots []*expr.AttributeExpr + for _, method := range serviceFacts.methods { + if !hasResultType(method.Result) { + continue + } + projected, source := projectedResultRoot(generation, method) + pairs := projectTypePairs(projected, source, seenProjected) + projection := &projectionFacts{pairs: pairs} + serviceFacts.projections[method] = projection + serviceFacts.methodByExpr[method].projection = projection + for _, pair := range pairs { + identity := codegen.NewProjectedTypeID(pair.source) + if _, err := views.DeclareDerivedType(identity, codegen.Goify(pair.projected.Name(), true)); err != nil { + return err + } + derived[pair.projected.Origin()] = identity + projectedFacts, err := collectProjectedTypeFacts(pair) + if err != nil { + return err + } + projection.types = append(projection.types, projectedFacts) + projectedFactsByOrigin[pair.source.Origin()] = projectedFacts + } + if resultType, ok := method.Result.Type.(*expr.ResultTypeExpr); ok { + projectedType := seenProjected[resultType.Origin()] + if len(pairs) > 0 { + projectedType = pairs[0].projected + } + if projectedType != nil { + viewName := "" + if !resultType.HasMultipleViews() { + viewName = expr.DefaultView + } + if selected, ok := method.Result.Meta.Last(expr.ViewMetaKey); ok { + viewName = selected + } + serviceFacts.methodByExpr[method].viewedResult = &viewedResultFacts{ + wrapped: wrapProjected(projectedType), + origin: resultType.Origin(), + source: serviceFacts.methodByExpr[method].result, + viewName: viewName, + views: projectedFactsByOrigin[resultType.Origin()].views, + conversions: projectedFactsByOrigin[resultType.Origin()].conversions, + projected: projectedFactsByOrigin[resultType.Origin()], + isCollection: expr.IsArray(method.Result.Type), + } + } + } + removeMeta(projected) + projectedRoots = append(projectedRoots, projected) + + if resultType, ok := method.Result.Type.(*expr.ResultTypeExpr); ok { + if _, err := views.DeclareDerivedType( + codegen.NewViewedResultTypeID(resultType), + codegen.Goify(resultType.Name(), true), + ); err != nil { + return err + } + } + } + seenUnions := make(map[expr.UserType]struct{}) + retainedUnions := make(map[codegen.UnionTypeID]struct{}) + for _, projected := range projectedRoots { + if err := planViewUnions(projected, views, derived, seenUnions, retainedUnions, &serviceFacts.viewUnions); err != nil { + return err + } + } + } + return nil +} + +// collectProjectedTypeFacts selects validation and conversion code for one set +// of result types containing only the fields in their declared views. +func collectProjectedTypeFacts(pair *projectedTypePair) (*projectedTypeFacts, error) { + facts := &projectedTypeFacts{ + pair: pair, + projectedType: pair.projected, + validations: collectValidationFacts(pair.projectedAttribute), + } + resultType, viewed := pair.projected.(*expr.ResultTypeExpr) + if !viewed { + return facts, nil + } + for _, view := range resultType.Views { + object := expr.AsObject(view.Type) + attributes := make([]string, len(*object)) + for index, field := range *object { + attributes[index] = field.Name + } + facts.views = append(facts.views, &viewRenderFacts{ + name: view.Name, + description: view.Description, + attributes: attributes, + }) + } + for _, toResult := range []bool{true, false} { + conversions, err := collectViewConversionFacts(pair.projectedAttribute, pair.sourceAttribute, toResult) + if err != nil { + return nil, err + } + facts.conversions = append(facts.conversions, conversions...) + } + return facts, nil +} + +// collectValidationFacts stores the field checks and child validation calls +// selected for each result type and view. +func collectValidationFacts(projected *expr.AttributeExpr) []*validationFacts { + userType := projected.Type.(expr.UserType) + resultType, viewed := userType.(*expr.ResultTypeExpr) + if !viewed { + return []*validationFacts{{ + attribute: userType.Attribute(), + alias: expr.IsAlias(userType), + pointer: !expr.IsPrimitive(projected.Type), + }} + } + facts := make([]*validationFacts, 0, len(resultType.Views)) + array := expr.AsArray(projected.Type) + for _, view := range resultType.Views { + validation := &validationFacts{viewName: view.Name, pointer: true} + if array != nil { + validation.collectionElem = array.ElemType + facts = append(facts, validation) + continue + } + object := &expr.Object{} + walkViewAttrs(expr.AsObject(projected.Type), view, func(name string, attribute, viewAttribute *expr.AttributeExpr) { + if _, ok := attribute.Type.(*expr.ResultTypeExpr); ok { + selectedView := "" + if explicit, ok := viewAttribute.Meta.Last(expr.ViewMetaKey); ok && explicit != expr.DefaultView { + selectedView = explicit + } + validation.fields = append(validation.fields, &validationFieldFacts{ + name: name, + attribute: attribute, + view: selectedView, + required: resultType.Attribute().IsRequired(name), + }) + return + } + object.Set(name, attribute) + }) + validation.attribute = &expr.AttributeExpr{Type: object, Validation: resultType.Validation} + facts = append(facts, validation) + } + return facts +} + +// markNeededViewValidators keeps only functions that can return an error. A +// parent is needed when it checks one of its own fields or calls a needed child. +func markNeededViewValidators(facts *serviceFacts) { + validations := make(map[viewValidationKey]*validationFacts) + for _, projection := range facts.projections { + for _, projected := range projection.types { + for _, validation := range projected.validations { + key := viewValidationKey{ + origin: projected.pair.projected.Origin(), + view: canonicalValidatorView(validation.viewName), + } + validations[key] = validation + if validation.collectionElem == nil && codegen.NeedsValidation(validation.attribute, viewValidationPolicy()) { + validation.needed = true + } + for _, field := range validation.fields { + validation.needed = validation.needed || field.required + } + } + } + } + + for changed := true; changed; { + changed = false + for _, validation := range validations { + if validation.needed { + continue + } + if validation.collectionElem != nil && neededValidation(validations, validation.collectionElem, validation.viewName) { + validation.needed = true + changed = true + continue + } + for _, field := range validation.fields { + if neededValidation(validations, field.attribute, field.view) { + validation.needed = true + changed = true + break + } + } + } + } +} + +// neededValidation reports whether the selected view-specific result type can +// return a validation error. +func neededValidation(validations map[viewValidationKey]*validationFacts, attribute *expr.AttributeExpr, view string) bool { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return false + } + validation := validations[viewValidationKey{ + origin: userType.Origin(), + view: canonicalValidatorView(view), + }] + return validation != nil && validation.needed +} + +// collectViewConversionFacts selects the fields in each declared result view +// and stores the conversion used in each direction. +func collectViewConversionFacts(projected, service *expr.AttributeExpr, toResult bool) ([]*viewConversionFacts, error) { + views := service.Type.(*expr.ResultTypeExpr).Views + projectedObject := expr.AsObject(projected.Type) + projectedArray := expr.AsArray(projected.Type) + if projectedArray != nil { + projectedObject = expr.AsObject(projectedArray.ElemType.Type) + } + result := make([]*viewConversionFacts, 0, len(views)) + for _, view := range views { + object := &expr.Object{} + walkViewAttrs(projectedObject, view, func(name string, attribute, _ *expr.AttributeExpr) { + object.Set(name, attribute) + }) + var narrowedType expr.DataType = object + if projectedArray != nil { + narrowedType = &expr.Array{ElemType: &expr.AttributeExpr{Type: &expr.ResultTypeExpr{ + UserTypeExpr: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: object}, + TypeName: projectedArray.ElemType.Type.Name(), + }, + }}} + } + narrowed := &expr.AttributeExpr{Type: &expr.ResultTypeExpr{ + UserTypeExpr: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: narrowedType}, + TypeName: projected.Type.Name(), + }, + Views: views, + Identifier: service.Type.(*expr.ResultTypeExpr).Identifier, + }} + source, target := service, narrowed + if toResult { + source, target = narrowed, service + } + conversion := &viewConversionFacts{ + toResult: toResult, + viewName: view.Name, + source: source, + target: target, + } + if projectedArray == nil { + conversion.transformTarget = expr.DupAtt(target) + targetObject := expr.AsObject(conversion.transformTarget.Type) + for _, field := range *targetObject { + if _, nested := field.Attribute.Type.(*expr.ResultTypeExpr); !nested { + continue + } + nestedView := "" + if selected := source.Type.(*expr.ResultTypeExpr).View(view.Name).Find(field.Name); selected != nil { + if explicit, ok := selected.Meta.Last(expr.ViewMetaKey); ok && explicit != expr.DefaultView { + nestedView = explicit + } + } + conversion.fields = append(conversion.fields, &viewConversionFieldFacts{ + name: field.Name, + attribute: field.Attribute, + view: nestedView, + }) + targetObject.Delete(field.Name) + } + plan, err := codegen.NewTransformPlan(source, conversion.transformTarget, "", nil) + if err != nil { + return nil, err + } + conversion.plan = plan + } + result = append(result, conversion) + } + return result, nil +} + +// planViewUnions submits every Goa OneOf type reachable from view-specific +// result types. Existing view types keep their declarations; a branch without +// one receives a generated alias declaration. +func planViewUnions(attribute *expr.AttributeExpr, generatedPackage *codegen.GeneratedPackage, derived map[expr.UserType]codegen.DerivedTypeID, seen map[expr.UserType]struct{}, retained map[codegen.UnionTypeID]struct{}, unions *[]*unionFacts) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + recurse := func(attribute *expr.AttributeExpr) error { + return planViewUnions(attribute, generatedPackage, derived, seen, retained, unions) + } + switch actual := attribute.Type.(type) { + case expr.UserType: + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return nil + } + seen[origin] = struct{}{} + return recurse(actual.Attribute()) + case *expr.Object: + for _, field := range *actual { + if err := recurse(field.Attribute); err != nil { + return err + } + } + case *expr.Array: + return recurse(actual.ElemType) + case *expr.Map: + if err := recurse(actual.KeyType); err != nil { + return err + } + return recurse(actual.ElemType) + case *expr.Union: + if _, err := generatedPackage.DeclareUnion(actual); err != nil { + return err + } + identity := codegen.NewUnionTypeID(actual) + if _, exists := retained[identity]; !exists { + retained[identity] = struct{}{} + declaration, err := generatedPackage.Union(actual) + if err != nil { + return err + } + *unions = append(*unions, &unionFacts{ + union: actual, + identity: identity, + typeKey: actual.GetTypeKey(), + valueKey: actual.GetValueKey(), + location: &codegen.Location{RelImportPath: "views"}, + declaration: declaration, + }) + } + for _, branch := range actual.Values { + if userType, ok := branch.Attribute.Type.(expr.UserType); ok { + if _, projected := derived[userType.Origin()]; !projected { + if _, err := generatedPackage.DeclareUnionBranchType(actual, branch.Name, userType); err != nil { + return err + } + } + } + if err := recurse(branch.Attribute); err != nil { + return err + } + } + } + return nil +} + +// newRootTypeSet records the authored types in one design so compiler-created +// copies can use the same generated Go declarations. Generated Goa OneOf branch +// aliases are not added. +func newRootTypeSet(root *expr.RootExpr) *rootTypeSet { + userTypes := &rootTypeSet{ + byOrigin: make(map[expr.UserType]expr.UserType, len(root.Types)+len(root.ResultTypes)+1), + } + for _, userType := range root.Types { + userTypes.add(userType) + } + for _, resultType := range root.ResultTypes { + userTypes.add(resultType) + } + userTypes.add(expr.ErrorResult) + return userTypes +} + +// generatedUnionBranch reports whether OneOf created a user type around a +// branch that was not declared as a user type in the design. +func generatedUnionBranch(branch *expr.NamedAttributeExpr, rootTypes *rootTypeSet) (expr.UserType, bool) { + userType, ok := branch.Attribute.Type.(expr.UserType) + if !ok { + return nil, false + } + return userType, !rootTypes.contains(userType) +} + +// add records one user type declared in this design under its original type. +func (s *rootTypeSet) add(userType expr.UserType) { + s.byOrigin[userType.Origin()] = userType +} + +// canonical returns the original authored declaration for a compiler-created +// copy from this design. Types originating in another design are returned +// unchanged. +func (s *rootTypeSet) canonical(userType expr.UserType) expr.UserType { + if canonical, ok := s.byOrigin[userType.Origin()]; ok { + return canonical + } + return userType +} + +// contains reports whether userType was declared in this design or copied from +// one of its declarations. +func (s *rootTypeSet) contains(userType expr.UserType) bool { + _, ok := s.byOrigin[userType.Origin()] + return ok +} + +// claimGeneratedPackage passes the relative path from design metadata to +// Generation unchanged. Generation can then reject two different path strings +// that resolve to the same output package. An absolute path is invalid metadata +// and does not select a package under the generated module. +func claimGeneratedPackage(generation *codegen.Generation, servicePath string, location *codegen.Location) (*codegen.GeneratedPackage, error) { + if location == nil { + return generation.ClaimPackage(servicePath) + } + if path.IsAbs(location.RelImportPath) { + return nil, fmt.Errorf("generated package location %q must be relative", location.RelImportPath) + } + claim := strings.TrimSuffix(generation.GenPkg(), "/") + "/" + location.RelImportPath + return generation.ClaimPackage(claim) +} + +// generatedPackagePath returns the cleaned import path selected by location, +// or the service package when location is nil. +func generatedPackagePath(genpkg, servicePath string, location *codegen.Location) string { + if location != nil { + return path.Join(genpkg, location.RelImportPath) + } + return servicePath +} diff --git a/codegen/service/imports.go b/codegen/service/imports.go new file mode 100644 index 0000000000..cb421b17fa --- /dev/null +++ b/codegen/service/imports.go @@ -0,0 +1,754 @@ +// This file chooses one Go package name for each import path and records which +// imports each generated service file uses. +package service + +import ( + "path" + "slices" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // importAliases returns the Go package name chosen for an import path in one + // output package before files are rendered. + importAliases struct { + generation *codegen.Generation + } + + // importCollector accumulates the imports referenced by one generated Go + // file while traversing recursive service type definitions. + importCollector struct { + aliases *importAliases + genpkg string + outputPackage string + paths map[string]struct{} + planning bool + err error + } + + // This record stores the complete package paths used by one emitted file and + // the import declarations built after all package names are chosen. + retainedFileImports struct { + outputPackage string + paths []string + specs []*codegen.ImportSpec + } + + // serviceFileImports keeps imports separate for files that emit different + // subsets of one service's types and runtime helpers. + serviceFileImports struct { + service retainedFileImports + endpoint retainedFileImports + client retainedFileImports + views retainedFileImports + serverInterceptors retainedFileImports + clientInterceptors retainedFileImports + interceptorWrappers retainedFileImports + exampleService retainedFileImports + exampleServerInterceptors retainedFileImports + exampleClientInterceptors retainedFileImports + } +) + +// AttributeImports returns the exact generated-type and metadata imports +// referenced by attributes, using the same chosen package names as service +// type references. +func (d *ServicesData) AttributeImports(outputPackage string, attributes ...*expr.AttributeExpr) []*codegen.ImportSpec { + collector := newImportCollector(d.aliases, d.generation.GenPkg(), outputPackage) + seen := make(map[expr.UserType]struct{}) + for _, attribute := range attributes { + collector.collectReferences(attribute, seen) + } + return collector.imports() +} + +// newImportAliases returns package-name lookups for the supplied generation. +// Service analysis and rendering use these lookups after all import names are +// chosen. +func newImportAliases(root *expr.RootExpr, generation *codegen.Generation) (*importAliases, error) { + if !generation.HasRoot(root) { + return nil, rootMembershipError(root) + } + return &importAliases{generation: generation}, nil +} + +// name returns the chosen Go package name for importPath. It panics when a +// renderer asks for a package that was not recorded during planning. +func (a *importAliases) name(outputPackage, importPath string) string { + return a.generation.Package(outputPackage).ImportName(importPath) +} + +// spec returns the completed import declaration for importPath. +func (a *importAliases) spec(outputPackage, importPath string) *codegen.ImportSpec { + return a.generation.Package(outputPackage).Import(importPath) +} + +// newImportCollector creates a file-scoped collector that omits imports of the +// package containing the generated file. +func newImportCollector(aliases *importAliases, genpkg, outputPackage string) *importCollector { + return &importCollector{ + aliases: aliases, + genpkg: genpkg, + outputPackage: outputPackage, + paths: make(map[string]struct{}), + } +} + +// newPlanningImportCollector creates the same path walker used by rendering +// and additionally declares each discovered metadata or generated-package +// preference in the generation alias catalog. +func newPlanningImportCollector(generation *codegen.Generation, outputPackage string) *importCollector { + return &importCollector{ + aliases: &importAliases{generation: generation}, + genpkg: generation.GenPkg(), + outputPackage: outputPackage, + paths: make(map[string]struct{}), + planning: true, + } +} + +// addPath records an explicitly referenced package unless it is the package +// currently being emitted. +func (c *importCollector) addPath(importPath string) { + if importPath != "" && importPath != c.outputPackage { + c.paths[importPath] = struct{}{} + } +} + +// collectDefinition records imports used to render an attribute definition. +// Named references stop traversal because their fields are emitted elsewhere. +func (c *importCollector) collectDefinition(attribute *expr.AttributeExpr) { + c.collectAttribute(attribute, false, nil) +} + +// collectReferences records imports used by recursive conversion and +// validation code, including types and metadata nested in named declarations. +func (c *importCollector) collectReferences(attribute *expr.AttributeExpr, seen map[expr.UserType]struct{}) { + c.collectAttribute(attribute, true, seen) +} + +// collectAttribute implements definition and recursive-reference traversal. +func (c *importCollector) collectAttribute(attribute *expr.AttributeExpr, expandNamed bool, seen map[expr.UserType]struct{}) { + if attribute == nil || attribute.Type == expr.Empty { + return + } + c.addMetaImport(attribute) + switch actual := attribute.Type.(type) { + case expr.UserType: + c.addLocation(codegen.UserTypeLocation(actual)) + if !expandNamed { + return + } + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + c.collectAttribute(actual.Attribute(), true, seen) + case *expr.Object: + for _, named := range *actual { + c.collectAttribute(named.Attribute, expandNamed, seen) + } + case *expr.Array: + c.collectAttribute(actual.ElemType, expandNamed, seen) + case *expr.Map: + c.collectAttribute(actual.KeyType, expandNamed, seen) + c.collectAttribute(actual.ElemType, expandNamed, seen) + case *expr.Union: + for _, named := range actual.Values { + c.collectAttribute(named.Attribute, expandNamed, seen) + } + } +} + +// addLocation records the generated package selected by location unless it is +// the package currently being emitted. +func (c *importCollector) addLocation(location *codegen.Location) { + if location == nil { + return + } + importPath := c.aliases.generation.Package(path.Join(c.genpkg, location.RelImportPath)).ImportPath() + if importPath != c.outputPackage { + c.paths[importPath] = struct{}{} + if c.planning && c.err == nil { + c.err = c.aliases.generation.Package(c.outputPackage).ReserveGeneratedImport(codegen.NewImport( + strings.ToLower(codegen.Goify(path.Base(importPath), false)), + importPath, + )) + } + } +} + +// addMetaImport records the package named by struct:field:type metadata unless +// the metadata refers to the package currently being emitted. +func (c *importCollector) addMetaImport(attribute *expr.AttributeExpr) { + _, spec := codegen.GetMetaType(attribute) + if spec != nil && spec.Path != c.outputPackage { + c.paths[spec.Path] = struct{}{} + if c.planning && c.err == nil { + c.err = c.aliases.generation.Package(c.outputPackage).DeclareImport(spec) + } + } +} + +// retainFileImports collects the fixed, generated, type-definition, and +// recursive-reference package paths used by one emitted file before +// Generation.Freeze chooses their Go package names. +func retainFileImports( + generation *codegen.Generation, + outputPackage string, + fixed, generated []*codegen.ImportSpec, + definitions, references []*expr.AttributeExpr, +) (retainedFileImports, error) { + collector := newPlanningImportCollector(generation, outputPackage) + owner := generation.Package(outputPackage) + for _, spec := range fixed { + collector.addPath(spec.Path) + if err := owner.RequireImport(spec); err != nil { + return retainedFileImports{}, err + } + } + for _, spec := range generated { + collector.addPath(spec.Path) + if err := owner.ReserveGeneratedImport(spec); err != nil { + return retainedFileImports{}, err + } + } + for _, attribute := range definitions { + collector.collectDefinition(attribute) + } + seen := make(map[expr.UserType]struct{}) + for _, attribute := range references { + collector.collectReferences(attribute, seen) + } + if collector.err != nil { + return retainedFileImports{}, collector.err + } + paths := make([]string, 0, len(collector.paths)) + for importPath := range collector.paths { + paths = append(paths, importPath) + } + sort.Strings(paths) + return retainedFileImports{outputPackage: outputPackage, paths: paths}, nil +} + +// linkFileImports converts one saved path list into import declarations after +// Generation.Freeze chooses the package names. It does not reread service +// attributes. +func linkFileImports(imports *retainedFileImports, generation *codegen.Generation) { + imports.specs = make([]*codegen.ImportSpec, len(imports.paths)) + if len(imports.paths) == 0 { + return + } + owner := generation.Package(imports.outputPackage) + for index, importPath := range imports.paths { + imports.specs[index] = owner.Import(importPath) + } +} + +// addRetainedImportPath adds one explicitly declared package to a file's saved +// path list in sorted order. +func addRetainedImportPath(imports *retainedFileImports, importPath string) { + index, found := slices.BinarySearch(imports.paths, importPath) + if found { + return + } + imports.paths = slices.Insert(imports.paths, index, importPath) +} + +// planServiceFileImports selects the package paths used by each concrete file +// emitted for one service copied into the plan. It requests their preferred Go +// package names before Generation.Freeze chooses the final names. +func planServiceFileImports(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + servicePath := facts.packagePath + serviceImport := facts.packageImport + viewsImport := facts.viewsImport + facts.generatedTypeImports = make(map[*codegen.TypeDeclaration]*retainedFileImports) + + definitions := serviceDefinitionAttributes(facts) + serviceDefinitions := append([]*expr.AttributeExpr(nil), facts.referenceAttributes...) + serviceDefinitions = append(serviceDefinitions, definitions...) + viewDefinitions := viewDefinitionAttributes(facts) + + contextImport := codegen.SimpleImport("context") + ioImport := codegen.SimpleImport("io") + goaImport := codegen.GoaImport("") + securityImport := codegen.GoaImport("security") + logImport := codegen.SimpleImport("goa.design/clue/log") + + serviceFixed := []*codegen.ImportSpec{contextImport} + if serviceUsesIO(facts) { + serviceFixed = append(serviceFixed, ioImport) + } + if serviceUsesGoaErrors(facts) { + serviceFixed = append(serviceFixed, goaImport) + } + if serviceHasSchemes(facts) { + serviceFixed = append(serviceFixed, securityImport) + } + var serviceGenerated []*codegen.ImportSpec + if len(facts.projections) > 0 { + serviceGenerated = append(serviceGenerated, viewsImport) + } + var err error + facts.imports.service, err = retainFileImports( + generation, servicePath, serviceFixed, serviceGenerated, serviceDefinitions, nil, + ) + if err != nil { + return err + } + + endpointFixed := []*codegen.ImportSpec{contextImport, goaImport} + if serviceUsesIO(facts) { + endpointFixed = append(endpointFixed, ioImport) + } + if serviceHasSchemes(facts) { + endpointFixed = append(endpointFixed, securityImport) + } + var endpointGenerated []*codegen.ImportSpec + for _, method := range facts.methods { + if facts.methodByExpr[method].viewedResult != nil { + endpointGenerated = append(endpointGenerated, viewsImport) + break + } + } + facts.imports.endpoint, err = retainFileImports( + generation, servicePath, endpointFixed, endpointGenerated, facts.referenceAttributes, nil, + ) + if err != nil { + return err + } + + clientFixed := []*codegen.ImportSpec{contextImport, goaImport} + if serviceUsesIO(facts) { + clientFixed = append(clientFixed, ioImport) + } + facts.imports.client, err = retainFileImports( + generation, servicePath, clientFixed, nil, facts.referenceAttributes, nil, + ) + if err != nil { + return err + } + + if len(facts.projections) > 0 { + viewsFixed := []*codegen.ImportSpec{goaImport} + validationFixed, validationGenerated := viewValidationImports(facts) + viewsFixed = append(viewsFixed, validationFixed...) + if len(facts.viewUnions) > 0 { + viewsFixed = append(viewsFixed, + codegen.SimpleImport("bytes"), + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("fmt"), + ) + } + facts.imports.views, err = retainFileImports( + generation, servicePath+"/views", viewsFixed, validationGenerated, viewDefinitions, nil, + ) + if err != nil { + return err + } + } + + interceptorFixed := []*codegen.ImportSpec{contextImport, goaImport} + if len(facts.serverInterceptors) > 0 { + serverInterceptorNames := interceptorNames(facts.serverInterceptorFacts) + serverInterceptorReferences := interceptorReferences(facts.serverInterceptorFacts) + serverInterceptorReferences = append( + serverInterceptorReferences, + interceptorReferencesOnly(facts.clientInterceptorFacts, serverInterceptorNames)..., + ) + facts.imports.serverInterceptors, err = retainFileImports( + generation, servicePath, interceptorFixed, nil, nil, serverInterceptorReferences, + ) + if err != nil { + return err + } + } + if len(facts.clientInterceptors) > 0 { + clientInterceptorReferences := interceptorReferencesWithout( + facts.clientInterceptorFacts, + interceptorNames(facts.serverInterceptorFacts), + ) + facts.imports.clientInterceptors, err = retainFileImports( + generation, servicePath, interceptorFixed, nil, nil, clientInterceptorReferences, + ) + if err != nil { + return err + } + } + if len(facts.serverInterceptors) > 0 || len(facts.clientInterceptors) > 0 { + facts.imports.interceptorWrappers, err = retainFileImports( + generation, servicePath, interceptorFixed, nil, nil, nil, + ) + if err != nil { + return err + } + } + + exampleFixed := []*codegen.ImportSpec{contextImport, logImport} + if serviceUsesIO(facts) { + exampleFixed = append(exampleFixed, ioImport) + } + if serviceUsesResponseBody(facts) { + exampleFixed = append(exampleFixed, codegen.SimpleImport("strings")) + } + if serviceHasSchemes(facts) { + exampleFixed = append(exampleFixed, codegen.SimpleImport("fmt"), securityImport) + } + facts.imports.exampleService, err = retainFileImports( + generation, path.Dir(generation.GenPkg()), exampleFixed, + []*codegen.ImportSpec{serviceImport}, facts.referenceAttributes, nil, + ) + if err != nil { + return err + } + + exampleInterceptorFixed := []*codegen.ImportSpec{contextImport, logImport, goaImport} + if len(facts.serverInterceptors) > 0 { + facts.imports.exampleServerInterceptors, err = retainFileImports( + generation, path.Join(path.Dir(generation.GenPkg()), "interceptors"), + exampleInterceptorFixed, []*codegen.ImportSpec{serviceImport}, nil, nil, + ) + if err != nil { + return err + } + } + if len(facts.clientInterceptors) > 0 { + facts.imports.exampleClientInterceptors, err = retainFileImports( + generation, path.Join(path.Dir(generation.GenPkg()), "interceptors"), + exampleInterceptorFixed, []*codegen.ImportSpec{serviceImport}, nil, nil, + ) + if err != nil { + return err + } + } + for _, userType := range append(append([]*userTypeFacts(nil), facts.userTypes...), facts.errorTypes...) { + if userType.location == nil { + continue + } + userType.imports, err = retainFileImports( + generation, + userType.declaration.PackagePath(), + nil, + nil, + []*expr.AttributeExpr{userType.userType.Attribute()}, + nil, + ) + if err != nil { + return err + } + facts.generatedTypeImports[userType.declaration] = &userType.imports + } + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + if attribute == nil || codegen.UserTypeLocation(attribute.Type) == nil { + continue + } + userType, ok := attribute.Type.(expr.UserType) + if !ok { + continue + } + if _, normalized := generation.NormalizedMethodType(userType); !normalized { + continue + } + owner := generation.Package(generatedPackagePath( + generation.GenPkg(), facts.packagePath, codegen.UserTypeLocation(userType), + )) + declaration, err := owner.UserType(rootTypes.canonical(userType)) + if err != nil { + return err + } + if _, exists := facts.generatedTypeImports[declaration]; exists { + continue + } + retained, err := retainFileImports( + generation, + declaration.PackagePath(), + nil, + nil, + []*expr.AttributeExpr{userType.Attribute()}, + nil, + ) + if err != nil { + return err + } + facts.generatedTypeImports[declaration] = &retained + } + } + unionFixed := []*codegen.ImportSpec{ + codegen.SimpleImport("bytes"), + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("fmt"), + goaImport, + } + for _, union := range facts.unions { + definitions := make([]*expr.AttributeExpr, 0, len(union.union.Values)*2) + for _, branch := range union.union.Values { + definitions = append(definitions, branch.Attribute) + if userType, ok := branch.Attribute.Type.(expr.UserType); ok { + definitions = append(definitions, userType.Attribute()) + } + } + union.imports, err = retainFileImports( + generation, + union.declaration.PackagePath(), + unionFixed, + nil, + definitions, + nil, + ) + if err != nil { + return err + } + } + return nil +} + +// interceptorReferences returns the selected fields whose Go types are written +// in an interceptor interface or accessor method. +func interceptorReferences(interceptors []*interceptorFacts) []*expr.AttributeExpr { + return interceptorReferencesWithout(interceptors, nil) +} + +// interceptorReferencesWithout returns selected interceptor fields except for +// interceptors whose names are emitted by another file. +func interceptorReferencesWithout(interceptors []*interceptorFacts, excluded map[string]struct{}) []*expr.AttributeExpr { + var references []*expr.AttributeExpr + for _, interceptor := range interceptors { + if _, skip := excluded[interceptor.name]; skip { + continue + } + references = append(references, interceptorValueReferences(interceptor)...) + } + return references +} + +// interceptorReferencesOnly returns references for interceptor definitions +// written in another file with the same name. +func interceptorReferencesOnly(interceptors []*interceptorFacts, included map[string]struct{}) []*expr.AttributeExpr { + var references []*expr.AttributeExpr + for _, interceptor := range interceptors { + if _, keep := included[interceptor.name]; !keep { + continue + } + references = append(references, interceptorValueReferences(interceptor)...) + } + return references +} + +// interceptorValueReferences returns the selected fields and the complete +// method values stored behind their generated accessors. +func interceptorValueReferences(interceptor *interceptorFacts) []*expr.AttributeExpr { + accesses := [][]*interceptorAccessFacts{ + interceptor.readPayloadFields, + interceptor.writePayloadFields, + interceptor.readResultFields, + interceptor.writeResultFields, + interceptor.readStreamingPayloadFields, + interceptor.writeStreamingPayloadFields, + interceptor.readStreamingResultFields, + interceptor.writeStreamingResultFields, + } + var references []*expr.AttributeExpr + for _, fields := range accesses { + for _, field := range fields { + references = append(references, field.attribute) + } + } + hasPayload := len(interceptor.readPayloadFields) > 0 || len(interceptor.writePayloadFields) > 0 + hasResult := len(interceptor.readResultFields) > 0 || len(interceptor.writeResultFields) > 0 + hasStreamingPayload := len(interceptor.readStreamingPayloadFields) > 0 || len(interceptor.writeStreamingPayloadFields) > 0 + hasStreamingResult := len(interceptor.readStreamingResultFields) > 0 || len(interceptor.writeStreamingResultFields) > 0 + for _, method := range interceptor.methods { + if hasPayload { + references = append(references, method.payload.attribute) + } + if hasResult { + references = append(references, method.result.attribute) + } + if hasStreamingPayload { + references = append(references, method.streamingPayload.attribute) + } + if hasStreamingResult { + references = append(references, method.result.attribute) + } + } + return references +} + +// interceptorNames returns the names of interceptors emitted in a file. +func interceptorNames(interceptors []*interceptorFacts) map[string]struct{} { + names := make(map[string]struct{}, len(interceptors)) + for _, interceptor := range interceptors { + names[interceptor.name] = struct{}{} + } + return names +} + +// viewValidationImports separates packages named directly by templates from +// generated packages whose import names may change to avoid a collision. +func viewValidationImports(facts *serviceFacts) (fixed, generated []*codegen.ImportSpec) { + for _, method := range facts.methods { + projection := facts.projections[method] + if projection == nil { + continue + } + for _, projected := range projection.types { + for _, validation := range projected.validations { + if validation.plan == nil { + continue + } + for _, preference := range validation.plan.ImportPreferences() { + spec := codegen.NewImport(preference.Name, preference.Path) + switch preference.Path { + case codegen.GoaImport("").Path, "unicode/utf8": + fixed = append(fixed, spec) + default: + generated = append(generated, spec) + } + } + } + } + } + return +} + +// linkServiceFileImports resolves every concrete file contribution after the +// Generation.Freeze chooses all imported package names. +func linkServiceFileImports(facts *serviceFacts, generation *codegen.Generation) { + imports := []*retainedFileImports{ + &facts.imports.service, + &facts.imports.endpoint, + &facts.imports.client, + &facts.imports.views, + &facts.imports.serverInterceptors, + &facts.imports.clientInterceptors, + &facts.imports.interceptorWrappers, + &facts.imports.exampleService, + &facts.imports.exampleServerInterceptors, + &facts.imports.exampleClientInterceptors, + } + for _, retained := range imports { + linkFileImports(retained, generation) + } + for _, userType := range append(append([]*userTypeFacts(nil), facts.userTypes...), facts.errorTypes...) { + linkFileImports(&userType.imports, generation) + } + for _, union := range facts.unions { + linkFileImports(&union.imports, generation) + } + for _, imports := range facts.generatedTypeImports { + linkFileImports(imports, generation) + } +} + +// serviceDefinitionAttributes returns the exact named definitions written to +// service.go in addition to method references. +func serviceDefinitionAttributes(facts *serviceFacts) []*expr.AttributeExpr { + var definitions []*expr.AttributeExpr + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + if attribute == nil || codegen.UserTypeLocation(attribute.Type) != nil { + continue + } + if userType, ok := attribute.Type.(expr.UserType); ok { + definitions = append(definitions, userType.Attribute()) + } + } + } + for _, userType := range append(append([]*userTypeFacts(nil), facts.userTypes...), facts.errorTypes...) { + if userType.location == nil { + definitions = append(definitions, userType.userType.Attribute()) + } + } + return definitions +} + +// viewDefinitionAttributes returns each view-specific definition emitted in +// the service views file exactly once, even when several entries point to the +// same attribute value. +func viewDefinitionAttributes(facts *serviceFacts) []*expr.AttributeExpr { + seen := make(map[*expr.AttributeExpr]struct{}) + var definitions []*expr.AttributeExpr + for _, projection := range facts.projections { + for _, projected := range projection.types { + attribute := projected.pair.projectedAttribute + if userType, ok := attribute.Type.(expr.UserType); ok { + attribute = userType.Attribute() + } + if _, ok := seen[attribute]; ok { + continue + } + seen[attribute] = struct{}{} + definitions = append(definitions, attribute) + } + } + return definitions +} + +// serviceUsesIO reports whether generated method signatures expose a raw +// request or response body stream. +func serviceUsesIO(facts *serviceFacts) bool { + for _, method := range facts.methodByExpr { + if method.skipRequestBodyEncodeDecode || method.skipResponseBodyEncodeDecode { + return true + } + } + return false +} + +// serviceUsesResponseBody reports whether the starter implementation creates +// a raw response body from a string reader. +func serviceUsesResponseBody(facts *serviceFacts) bool { + for _, method := range facts.methodByExpr { + if method.skipResponseBodyEncodeDecode { + return true + } + } + return false +} + +// serviceUsesGoaErrors reports whether service.go emits a constructor that +// calls the Goa service-error runtime. +func serviceUsesGoaErrors(facts *serviceFacts) bool { + for _, serviceError := range facts.errors { + if expr.IsErrorResult(serviceError.Type) { + return true + } + } + for _, method := range facts.methods { + for _, methodError := range method.Errors { + if expr.IsErrorResult(methodError.Type) { + return true + } + } + } + return false +} + +// imports returns a deterministic snapshot of the packages collected for one +// generated file. +func (c *importCollector) imports() []*codegen.ImportSpec { + paths := make([]string, 0, len(c.paths)) + for importPath := range c.paths { + paths = append(paths, importPath) + } + sort.Strings(paths) + imports := make([]*codegen.ImportSpec, len(paths)) + for i, importPath := range paths { + imports[i] = c.aliases.spec(c.outputPackage, importPath) + } + return imports +} diff --git a/codegen/service/imports_test.go b/codegen/service/imports_test.go new file mode 100644 index 0000000000..fa5f4c3ea7 --- /dev/null +++ b/codegen/service/imports_test.go @@ -0,0 +1,434 @@ +// This file verifies that service import subsets and qualified references use +// one deterministic full-path alias binding across every render analysis. +package service + +import ( + "go/format" + "path" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestPlanRejectsUnregisteredRoot verifies that service planning cannot create +// render state outside the roots owned by its generation. +func TestPlanRejectsUnregisteredRoot(t *testing.T) { + root := codegen.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "shared.Value", "example.com/local/shared", "shared") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", nil) + require.ErrorContains(t, planTestServices(root, generation), "does not belong") + require.NoError(t, generation.Freeze()) +} + +// TestPlanUsesCopiedGenerationRoots verifies that mutating root slices outside +// the generation cannot change which service designs planning and rendering +// accept. +func TestPlanUsesCopiedGenerationRoots(t *testing.T) { + first := codegen.RunDSL(t, func() { + dsl.Service("First", func() { + dsl.Method("Read", func() {}) + }) + }) + second := codegen.RunDSL(t, func() { + dsl.Service("Second", func() { + dsl.Method("Read", func() {}) + }) + }) + roots := []eval.Root{first} + generation := mustTestGeneration(t, "generated.local/gen", roots) + roots[0] = second + returnedRoots := generation.Roots() + returnedRoots[0] = second + + firstPlan, err := NewPlan(first, generation, expr.NewExampleGenerator(first.API.RandomizerFactory)) + require.NoError(t, err) + require.ErrorContains(t, planTestServices(second, generation), "does not belong") + require.NoError(t, generation.Freeze()) + roots[0] = nil + returnedRoots = generation.Roots() + returnedRoots[0] = second + require.NoError(t, firstPlan.Link()) + require.NotNil(t, firstPlan.Services().Get("First")) +} + +// TestFileImportsAreRetainedBeforeFreeze verifies that rendering uses the +// exact package paths selected with the file contribution, not a later walk +// over mutable service-analysis slices. +func TestFileImportsAreRetainedBeforeFreeze(t *testing.T) { + root := codegen.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan.facts.services[0].referenceAttributes = nil + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + + file := endpointFile(plan, plan.facts.services[0]) + code := renderSections(t, file.SectionTemplates) + require.Contains(t, code, `"generated.local/gen/types"`) +} + +// TestImportAliasesUsePathAsIdentity verifies that generator-owned imports +// retain their canonical qualifier when metadata prefers another spelling for +// the same complete package path. +func TestImportAliasesUsePathAsIdentity(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/values") + require.NoError(t, pkg.RequireImport(codegen.SimpleImport("encoding/json"))) + require.NoError(t, pkg.DeclareImport(codegen.NewImport("jason", "encoding/json"))) + require.NoError(t, generation.Freeze()) + + aliases := &importAliases{generation: generation} + require.Equal(t, "json", aliases.name(pkg.ImportPath(), "encoding/json")) + require.Equal(t, "encoding/json", aliases.spec(pkg.ImportPath(), "encoding/json").Path) +} + +// TestImportAliasPreferenceIsOrderIndependent verifies that two metadata +// spellings for one path produce the same frozen qualifier in either order. +func TestImportAliasPreferenceIsOrderIndependent(t *testing.T) { + freeze := func(first, second string) string { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/values") + require.NoError(t, pkg.DeclareImport(codegen.NewImport(first, "example.com/value"))) + require.NoError(t, pkg.DeclareImport(codegen.NewImport(second, "example.com/value"))) + require.NoError(t, generation.Freeze()) + return pkg.ImportName("example.com/value") + } + + require.Equal(t, freeze("alpha", "zeta"), freeze("zeta", "alpha")) +} + +// TestRegisteredRootsShareImportAliases verifies that every root analysis and +// relocated declaration consumes the one mapping frozen for the generation. +func TestRegisteredRootsShareImportAliases(t *testing.T) { + rootWithPreference := func(serviceName, typeName, preferred string) *expr.RootExpr { + return codegen.RunDSL(t, func() { + payload := dsl.Type(typeName, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", preferred+".Value", "example.com/shared/value", preferred) + }) + }) + dsl.Service(serviceName, func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + } + firstRoot := rootWithPreference("First", "FirstPayload", "zeta") + secondRoot := rootWithPreference("Second", "SecondPayload", "alpha") + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{firstRoot, secondRoot}) + plans, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.NoError(t, err) + firstPlan, secondPlan := plans[0], plans[1] + require.NoError(t, generation.Freeze()) + require.NoError(t, firstPlan.Link()) + require.NoError(t, secondPlan.Link()) + first := firstPlan.Services() + second := secondPlan.Services() + const outputPackage = "generated.local/gen/types" + require.Equal(t, "alpha", first.aliases.name(outputPackage, "example.com/shared/value")) + require.Equal(t, first.aliases.name(outputPackage, "example.com/shared/value"), second.aliases.name(outputPackage, "example.com/shared/value")) + + files := mustServiceFiles(t, firstPlan, secondPlan) + for _, name := range []string{"first_payload.go", "second_payload.go"} { + file := findFile(files, filepath.Join("gen", "types", name)) + require.NotNil(t, file) + code := renderSections(t, file.SectionTemplates) + require.Contains(t, code, `alpha "example.com/shared/value"`) + require.Contains(t, code, "alpha.Value") + } +} + +// TestEmittedUnionReservesFixedJSON verifies that an emitted union codec's +// encoding/json qualifier wins before a metadata package requests the same +// preferred name. Files without a union do not reserve this runtime import. +func TestEmittedUnionReservesFixedJSON(t *testing.T) { + root := codegen.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.OneOf("choice", func() { + dsl.Attribute("text", dsl.String) + }) + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "json.Value", "example.com/custom/json", "json") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + require.NoError(t, planTestServices(root, generation)) + require.NoError(t, generation.Freeze()) + aliases, err := newImportAliases(root, generation) + require.NoError(t, err) + const outputPackage = "generated.local/gen/values" + require.Equal(t, "json", aliases.name(outputPackage, "encoding/json")) + require.Equal(t, "json2", aliases.name(outputPackage, "example.com/custom/json")) +} + +// TestFixedTemplateAliasesBeatGeneratedPackages verifies that generated +// service paths cannot take qualifiers required by static Goa and log calls. +func TestFixedTemplateAliasesBeatGeneratedPackages(t *testing.T) { + root := codegen.RunDSL(t, func() { + interceptor := dsl.Interceptor("Trace") + for _, name := range []string{"Goa", "Log"} { + dsl.Service(name, func() { + dsl.ServerInterceptor(interceptor) + dsl.Method("Read", func() {}) + }) + } + }) + plan := mustServicePlan(t, root) + services := plan.Services() + outputPackage := path.Join(path.Dir(services.generation.GenPkg()), "interceptors") + require.Equal(t, "goa", services.aliases.name(outputPackage, codegen.GoaImport("").Path)) + require.Equal(t, "goa2", services.aliases.name(outputPackage, servicePackagePath(services.generation.GenPkg(), root.Service("Goa")))) + require.Equal(t, "log", services.aliases.name(outputPackage, "goa.design/clue/log")) + require.Equal(t, "log2", services.aliases.name(outputPackage, servicePackagePath(services.generation.GenPkg(), root.Service("Log")))) +} + +// TestMetadataImportKeepsItsPreferredAlias verifies that an import used only +// by design metadata is not renamed by an unused runtime package. +func TestDocumentedJSONMetadataUsesCanonicalAlias(t *testing.T) { + root := codegen.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Attribute("raw", dsl.String, func() { + dsl.Meta("struct:field:type", "jason.RawMessage", "encoding/json", "jason") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + plan := mustServicePlan(t, root) + file := findFile(mustServiceFiles(t, plan), filepath.Join("gen", "values", "service.go")) + require.NotNil(t, file) + code := renderSections(t, file.SectionTemplates) + require.Contains(t, code, "jason.RawMessage") + require.Equal(t, 1, strings.Count(code, `"encoding/json"`), code) + require.NotContains(t, code, "json.RawMessage") +} + +// TestExampleServiceUsesCanonicalGeneratedPackageQualifier verifies that a +// metadata package cannot steal the qualifier reserved for a generated +// service package. +func TestExampleServiceUsesCanonicalGeneratedPackageQualifier(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String, func() { + dsl.Meta("struct:field:type", "values.Value", "example.com/custom/values", "values") + }) + }) + }) + }) + plan := mustServicePlan(t, root) + services := plan.Services() + servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Values")) + outputPackage := path.Dir(services.generation.GenPkg()) + require.Equal(t, "values", services.aliases.name(outputPackage, servicePath)) + require.Equal(t, "values2", services.aliases.name(outputPackage, "example.com/custom/values")) + + files := ExampleServiceFiles(plan) + require.Len(t, files, 1) + code := renderSections(t, files[0].SectionTemplates) + _, err := format.Source([]byte(code)) + require.NoError(t, err, code) + require.Contains(t, code, "values.Service") + require.Contains(t, code, "p values2.Value") +} + +// TestExampleServiceReservesFixedQualifiers verifies that standard library +// and generated service imports retain their template qualifiers when service +// metadata or names request the same spelling. +func TestExampleServiceReservesFixedQualifiers(t *testing.T) { + root := codegen.RunDSL(t, func() { + result := dsl.Type("Result", func() { + dsl.Attribute("length", dsl.Int) + }) + dsl.Service("Fmt", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String, func() { + dsl.Meta("struct:field:type", "strings.Value", "example.com/custom/strings", "strings") + }) + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET("/") + dsl.SkipResponseBodyEncodeDecode() + dsl.Response(dsl.StatusOK, func() { + dsl.Header("length:Content-Length") + }) + }) + }) + }) + }) + plan := mustServicePlan(t, root) + services := plan.Services() + servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Fmt")) + outputPackage := path.Dir(services.generation.GenPkg()) + servicePkg := services.aliases.name(outputPackage, servicePath) + require.NotEqual(t, "fmt", servicePkg) + require.Equal(t, "strings2", services.aliases.name(outputPackage, "example.com/custom/strings")) + + files := ExampleServiceFiles(plan) + require.Len(t, files, 1) + code := renderSections(t, files[0].SectionTemplates) + _, err := format.Source([]byte(code)) + require.NoError(t, err, code) + require.Contains(t, code, servicePkg+".Service") + require.Contains(t, code, "p strings2.Value") +} + +// TestServiceUsesCanonicalViewsQualifier verifies that design metadata cannot +// steal the qualifier reserved for the generated views package. +func TestServiceUsesCanonicalViewsQualifier(t *testing.T) { + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.value", func() { + dsl.TypeName("Value") + dsl.Attribute("custom", dsl.String, func() { + dsl.Meta("struct:field:type", "valuesviews.Value", "example.com/custom/views", "valuesviews") + }) + dsl.View("default", func() { + dsl.Attribute("custom") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(result) + }) + }) + }) + plan := mustServicePlan(t, root) + services := plan.Services() + servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Values")) + viewsPath := servicePath + "/views" + require.Equal(t, "valuesviews", services.aliases.name(servicePath, viewsPath)) + require.Equal(t, "valuesviews2", services.aliases.name(servicePath, "example.com/custom/views")) + + file := findFile(mustServiceFiles(t, plan), filepath.Join("gen", "values", "service.go")) + require.NotNil(t, file) + code := renderSections(t, file.SectionTemplates) + _, err := format.Source([]byte(code)) + require.NoError(t, err, code) + require.Contains(t, code, `valuesviews "`+viewsPath+`"`) + require.Contains(t, code, `valuesviews2 "example.com/custom/views"`) +} + +// TestViewValidationReservesOnlyUsedImports verifies that validation +// without string-length checks leaves the utf8 package name available to a +// field type supplied by the design. +func TestViewValidationReservesOnlyUsedImports(t *testing.T) { + const customUTF8 = "example.com/custom/utf8" + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.value", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "utf8.Value", customUTF8, "utf8") + }) + dsl.Attribute("name", dsl.String, func() { + dsl.Pattern("^[a-z]+$") + }) + dsl.View("default", func() { + dsl.Attribute("value") + dsl.Attribute("name") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(result) + }) + }) + }) + plan := mustServicePlan(t, root) + outputPackage := servicePackagePath(plan.Services().generation.GenPkg(), root.Service("Values")) + "/views" + require.Equal(t, "utf8", plan.Services().aliases.name(outputPackage, customUTF8)) + + file := findFile(mustServiceFiles(t, plan), filepath.Join("gen", "values", "views", "view.go")) + require.NotNil(t, file) + code := renderSections(t, file.SectionTemplates) + require.Contains(t, code, `"`+customUTF8+`"`) + require.NotContains(t, code, `"unicode/utf8"`) +} + +// TestUnionFieldReferencesUseFixedImportAliases verifies that the qualifier in +// a union field type and the import declaration come from the same frozen path +// binding when encoding/json already owns the preferred json name. +func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + generatedPackage := mustClaimTestPackage(t, generation, "generated.local/gen/values") + require.NoError(t, generatedPackage.RequireImport(codegen.SimpleImport("encoding/json"))) + require.NoError(t, generatedPackage.DeclareImport(codegen.NewImport("values", "generated.local/gen/values"))) + require.NoError(t, generatedPackage.DeclareImport(codegen.NewImport("json", "example.com/custom/json"))) + branch := &expr.AttributeExpr{Type: expr.String, Meta: expr.MetaExpr{ + "struct:field:type": {"json.Value", "example.com/custom/json", "json"}, + }} + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{{ + Name: "external", + Attribute: branch, + }}, + } + declaration, err := generatedPackage.DeclareUnion(union) + require.NoError(t, err) + facts := &unionFacts{ + union: union, + identity: codegen.NewUnionTypeID(union), + typeKey: union.GetTypeKey(), + valueKey: union.GetValueKey(), + declaration: declaration, + } + require.NoError(t, planUnionRenderFacts(facts, nil, generatedPackage)) + require.NoError(t, generation.Freeze()) + aliases := &importAliases{generation: generation} + data := buildRetainedUnionTypeData(facts, aliases) + require.Equal(t, "json2.Value", data.Fields[0].FieldType) + + collector := newImportCollector(aliases, generation.GenPkg(), "generated.local/gen/values") + collector.collectDefinition(branch) + header := codegen.Header( + "Union types", + "values", + append([]*codegen.ImportSpec{codegen.SimpleImport("encoding/json")}, collector.imports()...), + ) + var rendered strings.Builder + require.NoError(t, header.Write(&rendered)) + require.Contains(t, rendered.String(), `"encoding/json"`) + require.Contains(t, rendered.String(), `json2 "example.com/custom/json"`) +} diff --git a/codegen/service/interceptor_data.go b/codegen/service/interceptor_data.go new file mode 100644 index 0000000000..431703e9a9 --- /dev/null +++ b/codegen/service/interceptor_data.go @@ -0,0 +1,154 @@ +// This file builds the values used to generate interceptor interfaces and +// wrappers. +package service + +import ( + "goa.design/goa/v3/codegen" +) + +// buildInterceptorData creates the data needed to generate interceptor code. +func buildInterceptorData(service *serviceFacts, facts *interceptorFacts, methods map[*methodFacts]*MethodData, resolver *declarationResolver, server bool) *InterceptorData { + lookup := func(role serviceNameRole, subject string) *codegen.NameDeclaration { + return service.names[serviceSymbolID{ + role: role, service: service.name, subject: subject, + }].declaration + } + data := &InterceptorData{ + InfoDeclaration: lookup(serviceInterceptorInfoNameRole, facts.name), + PayloadDeclaration: lookup(serviceInterceptorPayloadNameRole, facts.name), + ResultDeclaration: lookup(serviceInterceptorResultNameRole, facts.name), + StreamingPayloadDeclaration: lookup(serviceInterceptorStreamingPayloadNameRole, facts.name), + StreamingResultDeclaration: lookup(serviceInterceptorStreamingResultNameRole, facts.name), + Name: codegen.Goify(facts.name, true), + DesignName: facts.name, + Description: facts.description, + Service: service.name, + } + if len(facts.methods) == 0 { + return data + } + data.ReadPayload = formatInterceptorAccess(facts.readPayloadFields, resolver) + data.WritePayload = formatInterceptorAccess(facts.writePayloadFields, resolver) + data.ReadResult = formatInterceptorAccess(facts.readResultFields, resolver) + data.WriteResult = formatInterceptorAccess(facts.writeResultFields, resolver) + data.ReadStreamingPayload = formatInterceptorAccess(facts.readStreamingPayloadFields, resolver) + data.WriteStreamingPayload = formatInterceptorAccess(facts.writeStreamingPayloadFields, resolver) + data.ReadStreamingResult = formatInterceptorAccess(facts.readStreamingResultFields, resolver) + data.WriteStreamingResult = formatInterceptorAccess(facts.writeStreamingResultFields, resolver) + data.HasPayloadAccess = len(data.ReadPayload) > 0 || len(data.WritePayload) > 0 + data.HasResultAccess = len(data.ReadResult) > 0 || len(data.WriteResult) > 0 + data.HasStreamingPayloadAccess = len(data.ReadStreamingPayload) > 0 || len(data.WriteStreamingPayload) > 0 + data.HasStreamingResultAccess = len(data.ReadStreamingResult) > 0 || len(data.WriteStreamingResult) > 0 + for _, method := range facts.methods { + md := methods[method] + data.Methods = append(data.Methods, buildInterceptorMethodData(service, facts.name, md)) + if server { + md.ServerInterceptors = append(md.ServerInterceptors, facts.name) + } else { + md.ClientInterceptors = append(md.ClientInterceptors, facts.name) + } + } + return data +} + +// formatInterceptorAccess returns the generated name and type for each field +// that an interceptor may read or write. +func formatInterceptorAccess(facts []*interceptorAccessFacts, resolver *declarationResolver) []*AttributeData { + if len(facts) == 0 { + return nil + } + data := make([]*AttributeData, len(facts)) + for index, field := range facts { + data[index] = &AttributeData{ + Name: field.name, + TypeRef: field.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)).Ref(), + Pointer: field.pointer, + } + } + return data +} + +// buildInterceptorMethodData creates the data needed to generate interceptor +// method code. +func buildInterceptorMethodData(service *serviceFacts, interceptorName string, md *MethodData) *MethodInterceptorData { + declaration := func(role serviceNameRole) *codegen.NameDeclaration { + return service.names[serviceSymbolID{ + role: role, service: service.name, method: md.VarName, subject: interceptorName, + }].declaration + } + var serverStream, clientStream *StreamInterceptorData + if md.ServerStream != nil { + serverStream = &StreamInterceptorData{ + InterfaceDeclaration: md.ServerStreamDeclaration, + WrapperDeclaration: service.names[serviceSymbolID{ + role: serviceServerStreamWrapperNameRole, service: service.name, method: md.VarName, + }].declaration, + Interface: md.ServerStream.Interface, + SendName: md.ServerStream.SendName, + SendWithContextName: md.ServerStream.SendWithContextName, + SendTypeRef: md.ServerStream.SendTypeRef, + RecvName: md.ServerStream.RecvName, + RecvWithContextName: md.ServerStream.RecvWithContextName, + RecvTypeRef: md.ServerStream.RecvTypeRef, + MustClose: md.ServerStream.MustClose, + EndpointStruct: md.ServerStream.EndpointStruct, + } + } + if md.ClientStream != nil { + clientStream = &StreamInterceptorData{ + InterfaceDeclaration: md.ClientStreamDeclaration, + WrapperDeclaration: service.names[serviceSymbolID{ + role: serviceClientStreamWrapperNameRole, service: service.name, method: md.VarName, + }].declaration, + Interface: md.ClientStream.Interface, + SendName: md.ClientStream.SendName, + SendWithContextName: md.ClientStream.SendWithContextName, + SendTypeRef: md.ClientStream.SendTypeRef, + RecvName: md.ClientStream.RecvName, + RecvWithContextName: md.ClientStream.RecvWithContextName, + RecvTypeRef: md.ClientStream.RecvTypeRef, + MustClose: md.ClientStream.MustClose, + } + } + payloadAccessDeclaration := declaration(serviceInterceptorPayloadAccessNameRole) + resultAccessDeclaration := declaration(serviceInterceptorResultAccessNameRole) + streamingPayloadAccessDeclaration := declaration(serviceInterceptorStreamingPayloadAccessNameRole) + streamingResultAccessDeclaration := declaration(serviceInterceptorStreamingResultAccessNameRole) + var payloadAccess, resultAccess, streamingPayloadAccess, streamingResultAccess string + if payloadAccessDeclaration != nil { + payloadAccess = payloadAccessDeclaration.Name() + } + if resultAccessDeclaration != nil { + resultAccess = resultAccessDeclaration.Name() + } + if streamingPayloadAccessDeclaration != nil { + streamingPayloadAccess = streamingPayloadAccessDeclaration.Name() + } + if streamingResultAccessDeclaration != nil { + streamingResultAccess = streamingResultAccessDeclaration.Name() + } + return &MethodInterceptorData{ + InfoDeclaration: declaration(serviceInterceptorMethodInfoNameRole), + ServerUnaryInfoDeclaration: declaration(serviceInterceptorServerUnaryInfoNameRole), + ClientUnaryInfoDeclaration: declaration(serviceInterceptorClientUnaryInfoNameRole), + StreamingSendInfoDeclaration: declaration(serviceInterceptorStreamingSendInfoNameRole), + StreamingRecvInfoDeclaration: declaration(serviceInterceptorStreamingRecvInfoNameRole), + PayloadAccessDeclaration: payloadAccessDeclaration, + ResultAccessDeclaration: resultAccessDeclaration, + StreamingPayloadAccessDeclaration: streamingPayloadAccessDeclaration, + StreamingResultAccessDeclaration: streamingResultAccessDeclaration, + ServerWrapperDeclaration: declaration(serviceServerInterceptorWrapperNameRole), + ClientWrapperDeclaration: declaration(serviceClientInterceptorWrapperNameRole), + MethodName: md.VarName, + PayloadAccess: payloadAccess, + ResultAccess: resultAccess, + PayloadRef: md.PayloadRef, + ResultRef: md.ResultRef, + StreamingPayloadAccess: streamingPayloadAccess, + StreamingPayloadRef: md.StreamingPayloadRef, + StreamingResultAccess: streamingResultAccess, + StreamingResultRef: md.StreamingResultRef, + ClientStream: clientStream, + ServerStream: serverStream, + } +} diff --git a/codegen/service/interceptors.go b/codegen/service/interceptors.go index e34ca006d4..7773141949 100644 --- a/codegen/service/interceptors.go +++ b/codegen/service/interceptors.go @@ -1,28 +1,50 @@ +// This file generates service interceptor interfaces, call information, and +// endpoint wrappers. package service import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -// InterceptorsFiles returns the interceptors files for the given service. -func InterceptorsFiles(_ string, service *expr.ServiceExpr, services *ServicesData) []*codegen.File { +type ( + // endpointInterceptorWrapperData lists the interceptor wrappers called by + // one generated endpoint wrapper, in call order. + endpointInterceptorWrapperData struct { + Declaration *codegen.NameDeclaration + InterceptorsDeclaration *codegen.NameDeclaration + Method string + Service string + Wrappers []*codegen.NameDeclaration + } + + // interceptorWrappersData identifies the server or client interceptor + // interface and the interceptors called through it. + interceptorWrappersData struct { + Service string + InterceptorsDeclaration *codegen.NameDeclaration + Interceptors []*InterceptorData + } +) + +// interceptorsFiles generates interceptor files for one service. +func interceptorsFiles(plan *Plan, facts *serviceFacts) []*codegen.File { var files []*codegen.File - svc := services.Get(service.Name) + services := plan.Services() + svc := services.Get(facts.name) // Generate service-specific interceptor files if len(svc.ServerInterceptors) > 0 { - files = append(files, interceptorFile(svc, true)) + files = append(files, interceptorFile(svc, facts.imports.serverInterceptors.specs, true)) } if len(svc.ClientInterceptors) > 0 { - files = append(files, interceptorFile(svc, false)) + files = append(files, interceptorFile(svc, facts.imports.clientInterceptors.specs, false)) } // Generate wrapper file if this service has any interceptors if len(svc.ServerInterceptors) > 0 || len(svc.ClientInterceptors) > 0 { - files = append(files, wrapperFile(svc)) + files = append(files, wrapperFile(svc, facts.imports.interceptorWrappers.specs)) } return files @@ -30,7 +52,7 @@ func InterceptorsFiles(_ string, service *expr.ServiceExpr, services *ServicesDa // interceptorFile returns the file defining the interceptors. // This method is called twice, once for the server and once for the client. -func interceptorFile(svc *Data, server bool) *codegen.File { +func interceptorFile(svc *Data, imports []*codegen.ImportSpec, server bool) *codegen.File { filename := "client_interceptors.go" template := clientInterceptorsT section := "client-interceptors-type" @@ -44,10 +66,11 @@ func interceptorFile(svc *Data, server bool) *codegen.File { desc = svc.Name + desc path := filepath.Join(codegen.Gendir, svc.PathName, filename) - interceptors := svc.ServerInterceptors - if !server { - interceptors = svc.ClientInterceptors + interceptors := svc.ClientInterceptors + if server { + interceptors = mergeInterceptorDefinitions(svc.ServerInterceptors, svc.ClientInterceptors) } + appliedInterceptors := interceptors // We don't want to generate duplicate interceptor info data structures for // interceptors that are both server and client side so remove interceptors @@ -67,10 +90,7 @@ func interceptorFile(svc *Data, server bool) *codegen.File { } sections := []*codegen.SectionTemplate{ - codegen.Header(desc, svc.PkgName, []*codegen.ImportSpec{ - {Path: "context"}, - codegen.GoaImport(""), - }), + codegen.Header(desc, svc.PkgName, imports), { Name: section, Source: serviceTemplates.Read(template), @@ -96,20 +116,33 @@ func interceptorFile(svc *Data, server bool) *codegen.File { } for _, m := range svc.Methods { ints := m.ServerInterceptors + declaration := m.ServerEndpointWrapperDeclaration + interceptorsDeclaration := svc.ServerInterceptorsDeclaration if !server { ints = m.ClientInterceptors + declaration = m.ClientEndpointWrapperDeclaration + interceptorsDeclaration = svc.ClientInterceptorsDeclaration } if len(ints) == 0 { continue } + wrappers := make([]*codegen.NameDeclaration, len(ints)) + for index, name := range ints { + interceptor := interceptorMethod(appliedInterceptors, name, m.VarName) + wrappers[index] = interceptor.ServerWrapperDeclaration + if !server { + wrappers[index] = interceptor.ClientWrapperDeclaration + } + } sections = append(sections, &codegen.SectionTemplate{ Name: section, Source: serviceTemplates.Read(template), - Data: map[string]any{ - "MethodVarName": m.VarName, - "Method": m.Name, - "Service": svc.Name, - "Interceptors": ints, + Data: &endpointInterceptorWrapperData{ + Declaration: declaration, + InterceptorsDeclaration: interceptorsDeclaration, + Method: m.Name, + Service: svc.Name, + Wrappers: wrappers, }, }) } @@ -120,8 +153,8 @@ func interceptorFile(svc *Data, server bool) *codegen.File { Source: serviceTemplates.Read(interceptorsT), Data: interceptors, FuncMap: map[string]any{ - "hasPrivateImplementationTypes": hasPrivateImplementationTypes, - "hasEndpointStruct": hasEndpointStruct(server), + "hasPrivateAccessorMethods": hasPrivateAccessorMethods, + "hasEndpointStruct": hasEndpointStruct(server), }, }) } @@ -129,16 +162,41 @@ func interceptorFile(svc *Data, server bool) *codegen.File { return &codegen.File{Path: path, SectionTemplates: sections} } +// mergeInterceptorDefinitions adds client-only methods when the shared +// interceptor interface is written in the server file. This keeps every method +// that uses that interface in the same generated file. +func mergeInterceptorDefinitions(server, client []*InterceptorData) []*InterceptorData { + merged := make([]*InterceptorData, len(server)) + for index, interceptor := range server { + copy := *interceptor + copy.Methods = append([]*MethodInterceptorData(nil), interceptor.Methods...) + seen := make(map[string]struct{}, len(copy.Methods)) + for _, method := range copy.Methods { + seen[method.MethodName] = struct{}{} + } + for _, candidate := range client { + if candidate.DesignName != interceptor.DesignName { + continue + } + for _, method := range candidate.Methods { + if _, exists := seen[method.MethodName]; exists { + continue + } + copy.Methods = append(copy.Methods, method) + seen[method.MethodName] = struct{}{} + } + } + merged[index] = © + } + return merged +} + // wrapperFile returns the file containing the interceptor wrappers. -func wrapperFile(svc *Data) *codegen.File { +func wrapperFile(svc *Data, imports []*codegen.ImportSpec) *codegen.File { path := filepath.Join(codegen.Gendir, svc.PathName, "interceptor_wrappers.go") var sections []*codegen.SectionTemplate - sections = append(sections, codegen.Header("Interceptor wrappers", svc.PkgName, []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - codegen.GoaImport(""), - })) + sections = append(sections, codegen.Header("Interceptor wrappers", svc.PkgName, imports)) // Generate any interceptor stream wrapper struct types first var wrappedServerStreams, wrappedClientStreams []*StreamInterceptorData @@ -172,9 +230,10 @@ func wrapperFile(svc *Data) *codegen.File { sections = append(sections, &codegen.SectionTemplate{ Name: "server-interceptor-wrappers", Source: serviceTemplates.Read(serverInterceptorWrappersT), - Data: map[string]any{ - "Service": svc.Name, - "ServerInterceptors": svc.ServerInterceptors, + Data: &interceptorWrappersData{ + Service: svc.Name, + InterceptorsDeclaration: svc.ServerInterceptorsDeclaration, + Interceptors: svc.ServerInterceptors, }, }) } @@ -182,9 +241,10 @@ func wrapperFile(svc *Data) *codegen.File { sections = append(sections, &codegen.SectionTemplate{ Name: "client-interceptor-wrappers", Source: serviceTemplates.Read(clientInterceptorWrappersT), - Data: map[string]any{ - "Service": svc.Name, - "ClientInterceptors": svc.ClientInterceptors, + Data: &interceptorWrappersData{ + Service: svc.Name, + InterceptorsDeclaration: svc.ClientInterceptorsDeclaration, + Interceptors: svc.ClientInterceptors, }, }) } @@ -215,11 +275,40 @@ func wrapperFile(svc *Data) *codegen.File { } } -// hasPrivateImplementationTypes returns true if any of the interceptors have -// private implementation types. +// interceptorMethod returns the generated call information for one interceptor +// and service method. The design has already linked the interceptor to the +// method, so a missing entry is a generator bug. +func interceptorMethod(interceptors []*InterceptorData, name, method string) *MethodInterceptorData { + for _, interceptor := range interceptors { + if interceptor.DesignName != name { + continue + } + for _, candidate := range interceptor.Methods { + if candidate.MethodName == method { + return candidate + } + } + } + panic("retained interceptor method is missing") +} + +// hasPrivateImplementationTypes reports whether the file needs private structs +// that hold call information for a service method. func hasPrivateImplementationTypes(interceptors []*InterceptorData) bool { for _, intr := range interceptors { - if intr.ReadPayload != nil || intr.WritePayload != nil || intr.ReadResult != nil || intr.WriteResult != nil || intr.ReadStreamingPayload != nil || intr.WriteStreamingPayload != nil || intr.ReadStreamingResult != nil || intr.WriteStreamingResult != nil { + if len(intr.Methods) > 0 { + return true + } + } + return false +} + +// hasPrivateAccessorMethods reports whether an interceptor exposes selected +// payload or result fields through private accessor methods. +func hasPrivateAccessorMethods(interceptors []*InterceptorData) bool { + for _, interceptor := range interceptors { + if interceptor.HasPayloadAccess || interceptor.HasResultAccess || + interceptor.HasStreamingPayloadAccess || interceptor.HasStreamingResultAccess { return true } } @@ -247,14 +336,14 @@ func collectWrappedStreams(interceptors []*InterceptorData, server bool) []*Stre if intr.HasStreamingPayloadAccess || intr.HasStreamingResultAccess { for _, method := range intr.Methods { if server { - if _, ok := streamNames[method.ServerStream.Interface]; !ok { + if _, ok := streamNames[method.ServerStream.InterfaceDeclaration.Name()]; !ok { streams = append(streams, method.ServerStream) - streamNames[method.ServerStream.Interface] = struct{}{} + streamNames[method.ServerStream.InterfaceDeclaration.Name()] = struct{}{} } } else { - if _, ok := streamNames[method.ClientStream.Interface]; !ok { + if _, ok := streamNames[method.ClientStream.InterfaceDeclaration.Name()]; !ok { streams = append(streams, method.ClientStream) - streamNames[method.ClientStream.Interface] = struct{}{} + streamNames[method.ClientStream.InterfaceDeclaration.Name()] = struct{}{} } } } diff --git a/codegen/service/interceptors_test.go b/codegen/service/interceptors_test.go index 3dd2977b14..70c4ab5987 100644 --- a/codegen/service/interceptors_test.go +++ b/codegen/service/interceptors_test.go @@ -1,3 +1,5 @@ +// This file verifies generated server and client interceptor data, including +// selected payload and result attribute references. package service import ( @@ -13,9 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" - "goa.design/goa/v3/expr" ) var updateGolden = flag.Bool("update-interceptors", false, "update golden files for interceptor tests") @@ -31,8 +31,13 @@ func TestInterceptors(t *testing.T) { {"single-service-server-interceptor", testdata.SingleServiceServerInterceptorDSL, 2}, {"single-method-server-interceptor", testdata.SingleMethodServerInterceptorDSL, 2}, {"single-client-interceptor", testdata.SingleClientInterceptorDSL, 2}, + {"leading-initialism-interceptor", testdata.LeadingInitialismInterceptorDSL, 3}, {"multiple-interceptors", testdata.MultipleInterceptorsExampleDSL, 3}, {"interceptor-with-read-payload", testdata.InterceptorWithReadPayloadDSL, 3}, + {"interceptor-with-external-read-payload", testdata.InterceptorWithExternalReadPayloadDSL, 2}, + {"interceptor-with-external-payload", testdata.InterceptorWithExternalPayloadDSL, 2}, + {"mixed-interceptors-with-external-client-payload", testdata.MixedInterceptorsWithExternalClientPayloadDSL, 3}, + {"merged-interceptors-with-external-client-payload", testdata.MergedInterceptorsWithExternalClientPayloadDSL, 3}, {"interceptor-with-write-payload", testdata.InterceptorWithWritePayloadDSL, 3}, {"interceptor-with-read-write-payload", testdata.InterceptorWithReadWritePayloadDSL, 3}, {"interceptor-with-read-result", testdata.InterceptorWithReadResultDSL, 3}, @@ -41,19 +46,49 @@ func TestInterceptors(t *testing.T) { {"streaming-interceptors", testdata.StreamingInterceptorsDSL, 3}, {"streaming-interceptors-with-read-payload-and-read-streaming-payload", testdata.StreamingInterceptorsWithReadPayloadAndReadStreamingPayloadDSL, 3}, {"streaming-interceptors-with-read-streaming-result", testdata.StreamingInterceptorsWithReadStreamingResultDSL, 3}, + {"mixed-result-streaming-interceptors", testdata.MixedResultStreamingInterceptorsDSL, 3}, {"streaming-interceptors-with-read-payload", testdata.StreamingInterceptorsWithReadPayloadDSL, 2}, {"streaming-interceptors-with-read-result", testdata.StreamingInterceptorsWithReadResultDSL, 2}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := runDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := InterceptorsFiles("goa.design/goa/example", root.Services[0], services) + fs := interceptorsFiles(plan, plan.facts.services[0]) require.Len(t, fs, c.expectedFileCount) for _, f := range fs { + base := filepath.Base(f.Path) + if c.Name == "interceptor-with-external-read-payload" && base == "service_interceptors.go" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + require.Contains(t, header.String(), `types "goa.design/goa/example/types"`) + } + if c.Name == "interceptor-with-external-payload" && base == "service_interceptors.go" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + require.Contains(t, header.String(), `types "goa.design/goa/example/types"`) + } + if c.Name == "mixed-interceptors-with-external-client-payload" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + if base == "client_interceptors.go" { + require.Contains(t, header.String(), `types "goa.design/goa/example/types"`) + } else { + require.NotContains(t, header.String(), `"goa.design/goa/example/types"`) + } + } + if c.Name == "merged-interceptors-with-external-client-payload" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + if base == "service_interceptors.go" { + require.Contains(t, header.String(), `types "goa.design/goa/example/types"`) + } else { + require.NotContains(t, header.String(), `"goa.design/goa/example/types"`) + } + } buf := new(bytes.Buffer) for _, s := range f.SectionTemplates[1:] { require.NoError(t, s.Write(buf)) @@ -91,137 +126,6 @@ func TestInvalidInterceptors(t *testing.T) { } } -func TestCollectAttributes(t *testing.T) { - cases := []struct { - name string - attrNames *expr.AttributeExpr - parent *expr.AttributeExpr - want []*AttributeData - panics bool - }{ - { - name: "nil-attributes", - attrNames: nil, - parent: &expr.AttributeExpr{Type: &expr.Object{}}, - want: nil, - }, - { - name: "non-object-attributes", - attrNames: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}, - parent: &expr.AttributeExpr{Type: &expr.Object{}}, - want: nil, - }, - { - name: "simple-string-attribute", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - Validation: &expr.ValidationExpr{Required: []string{"name"}}, - }, - want: []*AttributeData{ - {Name: "Name", TypeRef: "string", Pointer: false}, - }, - }, - { - name: "pointer-primitive", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "age", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.IntKind)}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "age", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.IntKind), Meta: map[string][]string{"struct:field:pointer": {"true"}}}}, - }, - }, - want: []*AttributeData{ - {Name: "Age", TypeRef: "int", Pointer: true}, - }, - }, - { - name: "multiple-attributes", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - {Name: "age", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.IntKind)}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - {Name: "age", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.IntKind), Meta: map[string][]string{"struct:field:pointer": {"true"}}}}, - }, - Validation: &expr.ValidationExpr{Required: []string{"name"}}, - }, - want: []*AttributeData{ - {Name: "Name", TypeRef: "string", Pointer: false}, - {Name: "Age", TypeRef: "int", Pointer: true}, - }, - }, - { - name: "attribute-not-in-parent", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "missing", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - Validation: &expr.ValidationExpr{Required: []string{"name"}}, - }, - panics: true, - }, - { - name: "user-type-with-package", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "user", Attribute: &expr.AttributeExpr{Type: expr.String}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "user", Attribute: &expr.AttributeExpr{ - Type: &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - Meta: map[string][]string{ - "struct:pkg:path": {"goa.design/goa/example/user"}, - }, - }, - TypeName: "User", - }, - }}, - }, - }, - want: []*AttributeData{ - {Name: "User", TypeRef: "*user.User", Pointer: false}, - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - scope := codegen.NewNameScope() - if tc.panics { - assert.Panics(t, func() { collectAttributes(tc.attrNames, tc.parent, scope) }) - return - } - got := collectAttributes(tc.attrNames, tc.parent, scope) - assert.Equal(t, tc.want, got) - }) - } -} - func compareOrUpdateGolden(t *testing.T, code, golden string) { t.Helper() if *updateGolden { diff --git a/codegen/service/method_data.go b/codegen/service/method_data.go new file mode 100644 index 0000000000..0ad274d791 --- /dev/null +++ b/codegen/service/method_data.go @@ -0,0 +1,260 @@ +// This file builds the template data for service methods and their streams from +// the values and Go declarations recorded during planning. +package service + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// buildMethodData formats one method using the names and types chosen during +// service planning. +func buildMethodData(facts *methodFacts, resolver *declarationResolver, serviceFacts *serviceFacts) *MethodData { + var ( + vname string + desc string + payloadName string + payloadLoc *codegen.Location + payloadDef string + payloadRef string + payloadDesc string + payloadEx any + rname string + resultLoc *codegen.Location + resultDef string + resultRef string + resultDesc string + resultEx any + errors []*ErrorInitData + errorLocs map[string]*codegen.Location + reqs = facts.requirements + schemes = facts.schemes + ) + vname = facts.varName + desc = facts.description + if desc == "" { + desc = codegen.Goify(facts.name, true) + " implements " + facts.name + "." + } + if facts.payload != nil && facts.payload.present { + payloadLoc = facts.payload.location + payloadName, payloadDef, payloadRef = retainedMethodTypeData(facts.payload, resolver) + payloadDesc = facts.payload.description + if payloadDesc == "" { + payloadDesc = fmt.Sprintf("%s is the payload type of the %s service %s method.", + payloadName, serviceFacts.name, facts.name) + } + payloadEx = facts.payload.example + } + if facts.result != nil && facts.result.present { + resultLoc = facts.result.location + rname, resultDef, resultRef = retainedMethodTypeData(facts.result, resolver) + resultDesc = facts.result.description + if resultDesc == "" { + resultDesc = fmt.Sprintf("%s is the result type of the %s service %s method.", + rname, serviceFacts.name, facts.name) + } + resultEx = facts.result.example + } + if len(facts.errors) > 0 { + errors = make([]*ErrorInitData, len(facts.errors)) + errorLocs = make(map[string]*codegen.Location, len(facts.errors)) + for i, errorFacts := range facts.errors { + errors[i] = buildRetainedErrorInitData(errorFacts, resolver, serviceFacts.errorConstructors[errorFacts.name]) + errorLocs[errorFacts.name] = errorFacts.location + } + } + data := &MethodData{ + EndpointDeclaration: serviceFacts.names.declaration(serviceSymbolID{ + role: serviceMethodEndpointNameRole, service: serviceFacts.name, method: facts.varName, + }), + EndpointInputDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceEndpointInputNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ServerStreamDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceServerStreamNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ClientStreamDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceClientStreamNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + RequestDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceRequestNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ResponseDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceResponseNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ServerEndpointWrapperDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceServerEndpointWrapperNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ClientEndpointWrapperDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceClientEndpointWrapperNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + Name: facts.name, + VarName: vname, + Description: desc, + Idempotent: facts.idempotent, + Payload: payloadName, + PayloadLoc: payloadLoc, + PayloadDef: payloadDef, + PayloadRef: payloadRef, + PayloadDeclaration: facts.payload.layout.TypeDeclaration(), + PayloadDesc: payloadDesc, + PayloadEx: payloadEx, + PayloadDefault: facts.payload.defaultValue, + Result: rname, + ResultLoc: resultLoc, + ResultDef: resultDef, + ResultRef: resultRef, + ResultDeclaration: facts.result.layout.TypeDeclaration(), + ResultDesc: resultDesc, + ResultEx: resultEx, + Errors: errors, + ErrorLocs: errorLocs, + Requirements: reqs, + Schemes: schemes, + StreamKind: facts.streamKind, + HasMixedResults: facts.hasMixedResults, + SkipRequestBodyEncodeDecode: facts.skipRequestBodyEncodeDecode, + SkipResponseBodyEncodeDecode: facts.skipResponseBodyEncodeDecode, + RequestStruct: vname + "RequestData", + ResponseStruct: vname + "ResponseData", + EndpointField: facts.endpointField, + StreamEndpointField: facts.streamEndpointField, + } + + initStreamData(data, facts, resolver) + return data +} + +// initStreamData initializes the streaming payload data structures and methods. +func initStreamData(data *MethodData, facts *methodFacts, resolver *declarationResolver) { + if !facts.isStreaming && !facts.hasMixedResults { + return + } + var ( + spayloadName string + spayloadRef string + spayloadDef string + spayloadDesc string + spayloadEx any + srname string + srref string + srdef string + ) + if facts.streamingResult != nil && facts.streamingResult.present { + srname, srdef, srref = retainedMethodTypeData(facts.streamingResult, resolver) + } + data.StreamingResultRef = srref + + // Mixed-result methods return StreamingResult from their streaming endpoint + // and Result from their ordinary endpoint. + if facts.hasMixedResults && facts.streamingResult != nil && facts.streamingResult.present { + data.StreamingResult = srname + data.StreamingResultDef = srdef + data.StreamingResultDeclaration = facts.streamingResult.layout.TypeDeclaration() + data.StreamingResultDesc = facts.streamingResult.description + if data.StreamingResultDesc == "" { + data.StreamingResultDesc = fmt.Sprintf("%s is the streaming result type of the %s service %s method.", + srname, facts.serviceName, facts.name) + } + data.StreamingResultEx = facts.streamingResult.example + } + + if facts.streamingPayload != nil && facts.streamingPayload.present { + spayloadName, spayloadDef, spayloadRef = retainedMethodTypeData(facts.streamingPayload, resolver) + data.StreamingPayloadDeclaration = facts.streamingPayload.layout.TypeDeclaration() + spayloadDesc = facts.streamingPayload.description + if spayloadDesc == "" { + spayloadDesc = fmt.Sprintf("%s is the streaming payload type of the %s service %s method.", + spayloadName, facts.serviceName, facts.name) + } + spayloadEx = facts.streamingPayload.example + } + // Streaming endpoint calls carry the request value and stream together. + var endpointStruct string + if data.EndpointInputDeclaration != nil { + endpointStruct = data.EndpointInputDeclaration.Name() + } + // A mixed-result SSE method sends results from the server even though its + // service method is not otherwise marked as streaming. + streamKind := facts.streamKind + if facts.hasMixedResults && !facts.isStreaming { + streamKind = expr.ServerStreamKind + } + svrStream := &StreamData{ + Interface: data.ServerStreamDeclaration.Name(), + VarName: facts.serverStreamVarName, + EndpointStruct: endpointStruct, + Kind: streamKind, + SendName: "Send", + SendDesc: fmt.Sprintf("Send streams instances of %q.", srname), + SendWithContextName: "SendWithContext", + SendWithContextDesc: fmt.Sprintf("SendWithContext streams instances of %q with context.", srname), + SendTypeName: srname, + SendTypeRef: srref, + MustClose: true, + } + cliStream := &StreamData{ + Interface: data.ClientStreamDeclaration.Name(), + VarName: facts.clientStreamVarName, + Kind: streamKind, + RecvName: "Recv", + RecvDesc: fmt.Sprintf("Recv reads instances of %q from the stream.", srname), + RecvWithContextName: "RecvWithContext", + RecvWithContextDesc: fmt.Sprintf("RecvWithContext reads instances of %q from the stream with context.", srname), + RecvTypeName: srname, + RecvTypeRef: srref, + } + if streamKind == expr.ClientStreamKind || streamKind == expr.BidirectionalStreamKind { + switch streamKind { + case expr.ClientStreamKind: + if srref != "" { + svrStream.SendName = "SendAndClose" + svrStream.SendDesc = fmt.Sprintf("SendAndClose streams instances of %q and closes the stream.", srname) + svrStream.SendWithContextName = "SendAndCloseWithContext" + svrStream.SendWithContextDesc = fmt.Sprintf("SendAndCloseWithContext streams instances of %q and closes the stream with context.", srname) + svrStream.MustClose = false + cliStream.RecvName = "CloseAndRecv" + cliStream.RecvDesc = fmt.Sprintf("CloseAndRecv stops sending messages to the stream and reads instances of %q from the stream.", srname) + cliStream.RecvWithContextName = "CloseAndRecvWithContext" + cliStream.RecvWithContextDesc = fmt.Sprintf("CloseAndRecvWithContext stops sending messages to the stream and reads instances of %q from the stream with context.", srname) + } else { + cliStream.MustClose = true + } + case expr.BidirectionalStreamKind: + cliStream.MustClose = true + } + svrStream.RecvName = "Recv" + svrStream.RecvDesc = fmt.Sprintf("Recv reads instances of %q from the stream.", spayloadName) + svrStream.RecvWithContextName = "RecvWithContext" + svrStream.RecvWithContextDesc = fmt.Sprintf("RecvWithContext reads instances of %q from the stream with context.", spayloadName) + svrStream.RecvTypeName = spayloadName + svrStream.RecvTypeRef = spayloadRef + cliStream.SendName = "Send" + cliStream.SendDesc = fmt.Sprintf("Send streams instances of %q.", spayloadName) + cliStream.SendWithContextName = "SendWithContext" + cliStream.SendWithContextDesc = fmt.Sprintf("SendWithContext streams instances of %q with context.", spayloadName) + cliStream.SendTypeName = spayloadName + cliStream.SendTypeRef = spayloadRef + } + data.ClientStream = cliStream + data.ServerStream = svrStream + data.StreamingPayload = spayloadName + data.StreamingPayloadDef = spayloadDef + data.StreamingPayloadRef = spayloadRef + data.StreamingPayloadDesc = spayloadDesc + data.StreamingPayloadEx = spayloadEx +} + +// This helper returns the Go name, definition, and reference for one payload or +// result relative to the service output package. It reads the type layout +// copied during planning instead of rereading the design expression. +func retainedMethodTypeData(facts *methodAttributeFacts, resolver *declarationResolver) (string, string, string) { + linked := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)) + definition := "" + if facts.definition != nil { + definition = facts.definition.Link(facts.layout.Owner(), retainedTypeQualifier(resolver.aliases, facts.layout.Owner())).Def() + } + return linked.Name(), definition, linked.Ref() +} diff --git a/codegen/service/method_package_imports_test.go b/codegen/service/method_package_imports_test.go new file mode 100644 index 0000000000..221127595b --- /dev/null +++ b/codegen/service/method_package_imports_test.go @@ -0,0 +1,47 @@ +// This file verifies transport generators can retain the exact generated +// service and views package preferences chosen by a service plan. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestMethodPackageImportsReturnsPlannedServiceAndViewsPackages catches HTTP +// generators guessing a service or views package from their own output path. +func TestMethodPackageImportsReturnsPlannedServiceAndViewsPackages(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.storage.item", func() { + dsl.TypeName("Item") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("Storage", func() { + dsl.Method("Show", func() { + dsl.Result(result) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + + servicePackage, viewsPackage, err := plan.MethodPackageImports(root.Service("Storage").Method("Show")) + require.NoError(t, err) + require.Equal(t, &codegen.ImportSpec{Name: "storage", Path: "generated.local/gen/storage"}, servicePackage) + require.Equal(t, &codegen.ImportSpec{Name: "storageviews", Path: "generated.local/gen/storage/views"}, viewsPackage) + + servicePackage, viewsPackage, err = plan.ServicePackageImports(root.Service("Storage")) + require.NoError(t, err) + require.Equal(t, &codegen.ImportSpec{Name: "storage", Path: "generated.local/gen/storage"}, servicePackage) + require.Equal(t, &codegen.ImportSpec{Name: "storageviews", Path: "generated.local/gen/storage/views"}, viewsPackage) +} diff --git a/codegen/service/method_payload_layout_test.go b/codegen/service/method_payload_layout_test.go new file mode 100644 index 0000000000..40885c210e --- /dev/null +++ b/codegen/service/method_payload_layout_test.go @@ -0,0 +1,37 @@ +// This file verifies other generators can read the exact Go fields already +// chosen for a service method payload. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestMethodPayloadLayoutReturnsAssignedFieldNames verifies field metadata is +// applied once by the service planner and exposed without rebuilding the name. +func TestMethodPayloadLayoutReturnsAssignedFieldNames(t *testing.T) { + root := codegen.RunDSL(t, func() { + query := dsl.Type("ReadQuery", func() { + dsl.Attribute("cursor", dsl.String, func() { + dsl.Meta("struct:field:name", "OriginalCursor") + }) + }) + dsl.Service("Documents", func() { + dsl.Method("Read", func() { + dsl.Payload(query) + }) + }) + }) + plan := mustServicePlan(t, root) + + layout, err := plan.MethodPayloadLayout(root.Service("Documents").Method("Read")) + require.NoError(t, err) + require.Equal(t, codegen.GoStruct, layout.Kind()) + require.Len(t, layout.Fields(), 1) + require.Equal(t, "OriginalCursor", layout.Fields()[0].FieldName(true)) + require.True(t, layout.Fields()[0].IsPointer()) +} diff --git a/codegen/service/plan.go b/codegen/service/plan.go new file mode 100644 index 0000000000..72d7d95cdd --- /dev/null +++ b/codegen/service/plan.go @@ -0,0 +1,914 @@ +// This file stores the service declarations and file data selected for one +// generation run. File writers use this stored data without rereading the +// design. +package service + +import ( + "path" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // PlanInput supplies one evaluated Goa design and the generator used to make + // examples for types in that design. + PlanInput struct { + // Root is one evaluated Goa design included in this generation command. + Root *expr.RootExpr + // Examples produces the values written as examples for Root. + Examples *expr.ExampleGenerator + } + + // Plan stores one design's services, generated Go declarations, and template + // data from name collection through file rendering. + Plan struct { + generation *codegen.Generation + facts *rootFacts + services *ServicesData + } + + // rootFacts stores the services and API values selected from one design. + // Copying its slices prevents later steps from walking the design again and + // finding a different set of services. + rootFacts struct { + root *expr.RootExpr + apiName string + apiVersion string + examplePackageName string + exampleImports []*codegen.ImportSpec + services []*serviceFacts + serviceByID map[string]*serviceFacts + types []expr.UserType + rootTypes *rootTypeSet + examples *expr.ExampleGenerator + externalConversions []*externalConversionFileFacts + generatedTypes []*generatedTypeEmissionFacts + generatedUnions []*generatedUnionEmissionFacts + } + + // serviceFacts stores the service values needed to name and write its files. + serviceFacts struct { + service *expr.ServiceExpr + apiName string + name string + description string + packagePath string + viewsPath string + packageImport *codegen.ImportSpec + viewsImport *codegen.ImportSpec + methods []*expr.MethodExpr + orderedMethods []*methodFacts + methodByExpr map[*expr.MethodExpr]*methodFacts + errors []*expr.ErrorExpr + errorFacts []*errorRenderFacts + serverInterceptors []*expr.InterceptorExpr + clientInterceptors []*expr.InterceptorExpr + serverInterceptorFacts []*interceptorFacts + clientInterceptorFacts []*interceptorFacts + referenceAttributes []*expr.AttributeExpr + reachableTypes map[expr.UserType]struct{} + projections map[*expr.MethodExpr]*projectionFacts + userTypes []*userTypeFacts + errorTypes []*userTypeFacts + unions []*unionFacts + viewUnions []*unionFacts + names serviceNames + validators map[validatorKey]*codegen.NameDeclaration + errorConstructors map[string]*codegen.NameDeclaration + generatedTypeImports map[*codegen.TypeDeclaration]*retainedFileImports + exampleStruct *codegen.NameDeclaration + exampleConstructor *codegen.NameDeclaration + exampleServerStruct *codegen.NameDeclaration + exampleServerConstructor *codegen.NameDeclaration + exampleClientStruct *codegen.NameDeclaration + exampleClientConstructor *codegen.NameDeclaration + imports serviceFileImports + data *Data + } + + // methodFacts stores the method values used by service and transport files. + methodFacts struct { + method *expr.MethodExpr + serviceName string + name string + description string + idempotent bool + payload *methodAttributeFacts + result *methodAttributeFacts + streamingPayload *methodAttributeFacts + streamingResult *methodAttributeFacts + errors []*errorRenderFacts + requirements RequirementsData + schemes SchemesData + streamKind expr.StreamKind + isStreaming bool + hasMixedResults bool + varName string + serverStreamVarName string + clientStreamVarName string + endpointField string + streamEndpointField string + viewedResult *viewedResultFacts + projection *projectionFacts + skipRequestBodyEncodeDecode bool + skipResponseBodyEncodeDecode bool + } + + // methodAttributeFacts stores one payload or result description, default, and + // example. GoTypePlan separately stores the Go fields nested inside it. + methodAttributeFacts struct { + attribute *expr.AttributeExpr + layout *codegen.GoTypePlan + definition *codegen.GoTypePlan + normalized bool + present bool + isObject bool + location *codegen.Location + description string + defaultValue any + example any + } + + // errorRenderFacts stores the error type, description, and marker fields + // written to service, client, and endpoint files. + errorRenderFacts struct { + attribute *expr.AttributeExpr + layout *codegen.GoTypePlan + name string + description string + location *codegen.Location + temporary bool + timeout bool + fault bool + serviceType bool + } + + // interceptorFacts stores the methods that call one interceptor on either the + // client or server side. + interceptorFacts struct { + name string + description string + readPayload *expr.AttributeExpr + writePayload *expr.AttributeExpr + readResult *expr.AttributeExpr + writeResult *expr.AttributeExpr + readStreamingPayload *expr.AttributeExpr + writeStreamingPayload *expr.AttributeExpr + readStreamingResult *expr.AttributeExpr + writeStreamingResult *expr.AttributeExpr + readPayloadFields []*interceptorAccessFacts + writePayloadFields []*interceptorAccessFacts + readResultFields []*interceptorAccessFacts + writeResultFields []*interceptorAccessFacts + readStreamingPayloadFields []*interceptorAccessFacts + writeStreamingPayloadFields []*interceptorAccessFacts + readStreamingResultFields []*interceptorAccessFacts + writeStreamingResultFields []*interceptorAccessFacts + methods []*methodFacts + } + + // interceptorAccessFacts stores one field exposed to an interceptor and the + // Go type written for that field. + interceptorAccessFacts struct { + attribute *expr.AttributeExpr + name string + pointer bool + layout *codegen.GoTypePlan + } + + // projectionFacts stores copies of one method's result types containing only + // the fields in each selected view. Name collection and template data both + // read these same copies. + projectionFacts struct { + pairs []*projectedTypePair + types []*projectedTypeFacts + } + + // projectedTypeFacts stores one result type containing the fields selected by + // a view, together with the validation and conversion code generated for it. + projectedTypeFacts struct { + pair *projectedTypePair + projectedType expr.UserType + projected *codegen.GoTypePlan + definition *codegen.GoTypePlan + source *codegen.GoTypePlan + resultType bool + views []*viewRenderFacts + validations []*validationFacts + conversions []*viewConversionFacts + mapDeclaration *codegen.NameDeclaration + declaration *codegen.TypeDeclaration + } + + // viewRenderFacts stores the description and ordered field names from one + // declared result view for the service and views templates. + viewRenderFacts struct { + name string + description string + attributes []string + } + + // validationFacts stores the field checks and child validation calls emitted + // by one view-specific validation function. Function names are added later. + validationFacts struct { + viewName string + attribute *expr.AttributeExpr + layout *codegen.GoTypePlan + plan *codegen.ValidationPlan + declaration *codegen.NameDeclaration + needed bool + alias bool + pointer bool + collectionElem *expr.AttributeExpr + collectionCall *codegen.NameDeclaration + fields []*validationFieldFacts + } + + // validationFieldFacts stores one child result field and the validation call + // emitted for it. + validationFieldFacts struct { + name string + attribute *expr.AttributeExpr + view string + required bool + call *codegen.NameDeclaration + } + + // viewValidationKey identifies one result type and view while Goa decides + // whether its generated validation function can return an error. + viewValidationKey struct { + origin expr.UserType + view string + } + + // viewConversionFacts stores one conversion between a service result and a + // selected result view, including conversions for nested fields. + viewConversionFacts struct { + toResult bool + viewName string + source *expr.AttributeExpr + target *expr.AttributeExpr + transformTarget *expr.AttributeExpr + fields []*viewConversionFieldFacts + plan *codegen.TransformPlan + targetLayout *codegen.GoTypePlan + collection bool + contextType expr.UserType + contextIdentity codegen.DerivedTypeID + elementType expr.UserType + elementIdentity codegen.DerivedTypeID + constructor *codegen.NameDeclaration + elementCall *codegen.NameDeclaration + } + + // viewConversionFieldFacts stores one child result constructor call emitted + // separately from the general type conversion. + viewConversionFieldFacts struct { + name string + attribute *expr.AttributeExpr + view string + call *codegen.NameDeclaration + } + + // viewedResultFacts stores the wrapper type and selected view written for one + // method result. + viewedResultFacts struct { + wrapped expr.UserType + wrappedLayout *codegen.GoTypePlan + wrappedDef *codegen.GoTypePlan + projected *projectedTypeFacts + origin expr.UserType + source *methodAttributeFacts + viewName string + views []*viewRenderFacts + conversions []*viewConversionFacts + toViewed *codegen.NameDeclaration + toResult *codegen.NameDeclaration + mapDeclaration *codegen.NameDeclaration + declaration *codegen.TypeDeclaration + validator *codegen.NameDeclaration + validationCalls []*codegen.NameDeclaration + isCollection bool + } + + // userTypeFacts records one selected design type, its generated Go type, and + // the file location inherited from an enclosing type when needed. + userTypeFacts struct { + userType expr.UserType + name string + description string + errorName string + serviceError bool + location *codegen.Location + declaration *codegen.TypeDeclaration + layout *codegen.GoTypePlan + imports retainedFileImports + } + + // unionFacts records one Goa OneOf type and its generated Go declaration. + unionFacts struct { + union *expr.Union + identity codegen.UnionTypeID + typeKey string + valueKey string + branches []*unionBranchFacts + location *codegen.Location + declaration *codegen.UnionDeclaration + imports retainedFileImports + data *UnionTypeData + } + + // unionBranchFacts stores one Goa OneOf branch, its generated names, and the + // Go type written for its value. + unionBranchFacts struct { + name string + fieldName string + declaration *codegen.UnionBranchDeclaration + layout *codegen.GoTypePlan + nilable bool + emitPrimitiveAlias bool + primitiveAliasType string + } + + // validatorKey selects the validation function for one generated result type + // and view. Validation code for view-specific result copies uses that exact + // function. + validatorKey struct { + declaration *codegen.TypeDeclaration + view string + } + + // viewConversionCallKey identifies one private constructor by the source + // result declaration, selected view, and conversion direction. + viewConversionCallKey struct { + origin expr.UserType + view string + toResult bool + } + + // streamWrapperKey identifies the client or server wrapper for one method's + // stream. + streamWrapperKey struct { + method *expr.MethodExpr + server bool + } +) + +// collectServiceNames submits every package-level Go declaration written for +// one service and its views before Generation.Freeze chooses the final Go +// names. +func collectServiceNames(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + service := facts.service + serviceName := service.Name + servicePackage := generation.Package(facts.packagePath) + viewsPackage := generation.Package(facts.viewsPath) + examplePackage, err := generation.ClaimOutputPackage(path.Dir(generation.GenPkg()), ".") + if err != nil { + return err + } + exampleInterceptorsPackage, err := generation.ClaimOutputPackage( + path.Join(path.Dir(generation.GenPkg()), "interceptors"), + "interceptors", + ) + if err != nil { + return err + } + facts.names = make(serviceNames) + facts.validators = make(map[validatorKey]*codegen.NameDeclaration) + facts.errorConstructors = make(map[string]*codegen.NameDeclaration) + declare := func(pkg *codegen.GeneratedPackage, role serviceNameRole, preferred string, id serviceSymbolID) error { + id.role = role + id.service = serviceName + _, err := facts.names.declareForAPI(pkg, id, preferred, facts.apiName) + return err + } + static := []struct { + role serviceNameRole + preferred string + }{ + {serviceInterfaceNameRole, "Service"}, + {serviceAPINameRole, "APIName"}, + {serviceAPIVersionNameRole, "APIVersion"}, + {serviceNameConstantRole, "ServiceName"}, + {serviceMethodNamesRole, "MethodNames"}, + {serviceEndpointsNameRole, "Endpoints"}, + {serviceNewEndpointsNameRole, "NewEndpoints"}, + {serviceClientNameRole, "Client"}, + {serviceNewClientNameRole, "NewClient"}, + } + if serviceHasSchemes(facts) { + static = append(static, struct { + role serviceNameRole + preferred string + }{serviceAutherNameRole, "Auther"}) //nolint:misspell // Keep Goa's existing generated interface name. + } + for _, symbol := range static { + if err := declare(servicePackage, symbol.role, symbol.preferred, serviceSymbolID{}); err != nil { + return err + } + } + facts.exampleStruct, err = facts.names.declareForAPI(examplePackage, serviceSymbolID{ + role: serviceExampleStructNameRole, service: serviceName, + }, codegen.Goify(serviceName, false)+"srvc", facts.apiName) + if err != nil { + return err + } + facts.exampleConstructor, err = facts.names.declareForAPI(examplePackage, serviceSymbolID{ + role: serviceExampleConstructorNameRole, service: serviceName, + }, "New"+codegen.Goify(serviceName, true), facts.apiName) + if err != nil { + return err + } + structName := codegen.Goify(serviceName, true) + if len(facts.serverInterceptors) > 0 { + facts.exampleServerStruct, err = facts.names.declareForAPI(exampleInterceptorsPackage, serviceSymbolID{ + role: serviceExampleServerInterceptorsStructNameRole, service: serviceName, + }, structName+"ServerInterceptors", facts.apiName) + if err != nil { + return err + } + facts.exampleServerConstructor, err = facts.names.declareForAPI(exampleInterceptorsPackage, serviceSymbolID{ + role: serviceExampleServerInterceptorsConstructorNameRole, service: serviceName, + }, "New"+structName+"ServerInterceptors", facts.apiName) + if err != nil { + return err + } + } + if len(facts.clientInterceptors) > 0 { + facts.exampleClientStruct, err = facts.names.declareForAPI(exampleInterceptorsPackage, serviceSymbolID{ + role: serviceExampleClientInterceptorsStructNameRole, service: serviceName, + }, structName+"ClientInterceptors", facts.apiName) + if err != nil { + return err + } + facts.exampleClientConstructor, err = facts.names.declareForAPI(exampleInterceptorsPackage, serviceSymbolID{ + role: serviceExampleClientInterceptorsConstructorNameRole, service: serviceName, + }, "New"+structName+"ClientInterceptors", facts.apiName) + if err != nil { + return err + } + } + for _, method := range facts.methods { + methodFacts := facts.methodByExpr[method] + methodID := serviceSymbolID{method: methodFacts.varName} + if method.IsStreaming() || method.HasMixedResults() { + if err := declare(servicePackage, serviceServerStreamNameRole, methodFacts.varName+"ServerStream", methodID); err != nil { + return err + } + if err := declare(servicePackage, serviceClientStreamNameRole, methodFacts.varName+"ClientStream", methodID); err != nil { + return err + } + if err := declare(servicePackage, serviceEndpointInputNameRole, methodFacts.varName+"EndpointInput", methodID); err != nil { + return err + } + } + if err := declare(servicePackage, serviceMethodEndpointNameRole, "New"+methodFacts.varName+"Endpoint", methodID); err != nil { + return err + } + if methodFacts.skipRequestBodyEncodeDecode { + if err := declare(servicePackage, serviceRequestNameRole, methodFacts.varName+"RequestData", methodID); err != nil { + return err + } + } + if methodFacts.skipResponseBodyEncodeDecode { + if err := declare(servicePackage, serviceResponseNameRole, methodFacts.varName+"ResponseData", methodID); err != nil { + return err + } + } + if len(method.ServerInterceptors) > 0 { + if err := declare(servicePackage, serviceServerEndpointWrapperNameRole, "Wrap"+methodFacts.varName+"Endpoint", methodID); err != nil { + return err + } + } + if len(method.ClientInterceptors) > 0 { + if err := declare(servicePackage, serviceClientEndpointWrapperNameRole, "Wrap"+methodFacts.varName+"ClientEndpoint", methodID); err != nil { + return err + } + } + } + if err := collectErrorNames(facts, servicePackage); err != nil { + return err + } + if err := collectInterceptorNames(facts, servicePackage); err != nil { + return err + } + return collectViewNames(facts, servicePackage, viewsPackage, rootTypes, generation) +} + +// serviceHasSchemes reports whether any selected method needs generated +// authorization functions. +func serviceHasSchemes(facts *serviceFacts) bool { + for _, method := range facts.methods { + if len(method.Requirements) > 0 { + return true + } + } + return false +} + +// collectErrorNames declares the constructors emitted for distinct Goa +// service errors shared by service-level and method-level declarations. +func collectErrorNames(facts *serviceFacts, servicePackage *codegen.GeneratedPackage) error { + seen := make(map[string]struct{}) + errors := append([]*expr.ErrorExpr(nil), facts.errors...) + for _, method := range facts.methods { + errors = append(errors, method.Errors...) + } + for _, serviceError := range errors { + if !expr.IsErrorResult(serviceError.Type) { + continue + } + if _, exists := seen[serviceError.Name]; exists { + continue + } + seen[serviceError.Name] = struct{}{} + declaration, err := facts.names.declareForAPI(servicePackage, serviceSymbolID{ + role: serviceErrorConstructorNameRole, + service: facts.service.Name, + subject: serviceError.Name, + }, "Make"+codegen.Goify(serviceError.Name, true), facts.apiName) + if err != nil { + return err + } + facts.errorConstructors[serviceError.Name] = declaration + } + return nil +} + +// collectInterceptorNames declares interceptor interfaces, typed accessors, +// wrappers, and stream wrapper structs in the service package. +func collectInterceptorNames(facts *serviceFacts, servicePackage *codegen.GeneratedPackage) error { + declare := func(role serviceNameRole, preferred string, id serviceSymbolID) error { + id.role = role + id.service = facts.service.Name + _, err := facts.names.declareForAPI(servicePackage, id, preferred, facts.apiName) + return err + } + if len(facts.serverInterceptors) > 0 { + if err := declare(serviceServerInterceptorsNameRole, "ServerInterceptors", serviceSymbolID{}); err != nil { + return err + } + } + if len(facts.clientInterceptors) > 0 { + if err := declare(serviceClientInterceptorsNameRole, "ClientInterceptors", serviceSymbolID{}); err != nil { + return err + } + } + interceptors := append(append([]*expr.InterceptorExpr(nil), facts.serverInterceptors...), facts.clientInterceptors...) + seenInterceptors := make(map[string]struct{}) + seenStreams := make(map[streamWrapperKey]struct{}) + for _, interceptor := range interceptors { + if _, exists := seenInterceptors[interceptor.Name]; !exists { + seenInterceptors[interceptor.Name] = struct{}{} + base := codegen.Goify(interceptor.Name, true) + for _, symbol := range []struct { + role serviceNameRole + suffix string + emit bool + }{ + {serviceInterceptorInfoNameRole, "Info", true}, + {serviceInterceptorPayloadNameRole, "Payload", interceptor.ReadPayload != nil || interceptor.WritePayload != nil}, + {serviceInterceptorResultNameRole, "Result", interceptor.ReadResult != nil || interceptor.WriteResult != nil}, + {serviceInterceptorStreamingPayloadNameRole, "StreamingPayload", interceptor.ReadStreamingPayload != nil || interceptor.WriteStreamingPayload != nil}, + {serviceInterceptorStreamingResultNameRole, "StreamingResult", interceptor.ReadStreamingResult != nil || interceptor.WriteStreamingResult != nil}, + } { + if !symbol.emit { + continue + } + if err := declare(symbol.role, base+symbol.suffix, serviceSymbolID{subject: interceptor.Name}); err != nil { + return err + } + } + } + for _, method := range facts.methods { + server := interceptorNamed(method.ServerInterceptors, interceptor.Name) + client := interceptorNamed(method.ClientInterceptors, interceptor.Name) + if !server && !client { + continue + } + methodName := facts.methodByExpr[method].varName + base := codegen.Goify(codegen.SnakeCase(interceptor.Name), false) + methodName + methodID := serviceSymbolID{method: facts.methodByExpr[method].varName, subject: interceptor.Name} + streamingAccess := interceptorHasStreamingAccess(interceptor) && (method.IsStreaming() || method.HasMixedResults()) + hasPayloadAccess := interceptor.ReadPayload != nil || interceptor.WritePayload != nil + hasStreamingPayloadAccess := interceptor.ReadStreamingPayload != nil || interceptor.WriteStreamingPayload != nil + hasStreamingResultAccess := interceptor.ReadStreamingResult != nil || interceptor.WriteStreamingResult != nil + for _, symbol := range []struct { + role serviceNameRole + suffix string + emit bool + }{ + {serviceInterceptorPayloadAccessNameRole, "Payload", hasPayloadAccess}, + {serviceInterceptorResultAccessNameRole, "Result", interceptor.ReadResult != nil || interceptor.WriteResult != nil}, + {serviceInterceptorStreamingPayloadAccessNameRole, "StreamingPayload", hasStreamingPayloadAccess}, + {serviceInterceptorStreamingResultAccessNameRole, "StreamingResult", hasStreamingResultAccess}, + {serviceInterceptorMethodInfoNameRole, "Info", true}, + {serviceInterceptorServerUnaryInfoNameRole, "ServerUnaryInfo", server && (!streamingAccess || hasPayloadAccess)}, + {serviceInterceptorClientUnaryInfoNameRole, "ClientUnaryInfo", client && (!streamingAccess || hasPayloadAccess)}, + {serviceInterceptorStreamingSendInfoNameRole, "StreamingSendInfo", streamingAccess && (server && hasStreamingResultAccess || client && hasStreamingPayloadAccess)}, + {serviceInterceptorStreamingRecvInfoNameRole, "StreamingRecvInfo", streamingAccess && (server && hasStreamingPayloadAccess || client && hasStreamingResultAccess)}, + } { + if !symbol.emit { + continue + } + if err := declare(symbol.role, base+symbol.suffix, methodID); err != nil { + return err + } + } + if server { + if err := declare(serviceServerInterceptorWrapperNameRole, "wrap"+methodName+codegen.Goify(interceptor.Name, true), methodID); err != nil { + return err + } + } + if client { + if err := declare(serviceClientInterceptorWrapperNameRole, "wrapClient"+methodName+codegen.Goify(interceptor.Name, true), methodID); err != nil { + return err + } + } + if (!method.IsStreaming() && !method.HasMixedResults()) || !interceptorHasStreamingAccess(interceptor) { + continue + } + for _, side := range []struct { + server bool + role serviceNameRole + name string + }{ + {true, serviceServerStreamWrapperNameRole, "wrapped" + methodName + "ServerStream"}, + {false, serviceClientStreamWrapperNameRole, "wrapped" + methodName + "ClientStream"}, + } { + key := streamWrapperKey{method: method, server: side.server} + if _, exists := seenStreams[key]; exists || side.server && !server || !side.server && !client { + continue + } + seenStreams[key] = struct{}{} + if err := declare(side.role, side.name, serviceSymbolID{method: methodName}); err != nil { + return err + } + } + } + } + return nil +} + +// collectViewNames declares validators and constructor/map companions from +// the exact service and view type declarations allocated during view planning. +func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen.GeneratedPackage, rootTypes *rootTypeSet, generation *codegen.Generation) error { + markNeededViewValidators(facts) + for _, method := range facts.methods { + projection := facts.projections[method] + if projection == nil { + continue + } + for _, projectedFacts := range projection.types { + pair := projectedFacts.pair + declaration, err := viewsPackage.DerivedType(codegen.NewProjectedTypeID(pair.source)) + if err != nil { + return err + } + projectedFacts.declaration = declaration + for _, validation := range projectedFacts.validations { + if !validation.needed { + continue + } + view := canonicalValidatorView(validation.viewName) + suffix := "" + if view != "" { + suffix = codegen.Goify(view, true) + } + key := validatorKey{declaration: declaration, view: view} + if facts.validators[key] != nil { + continue + } + id := serviceSymbolID{ + role: serviceValidatorNameRole, + service: facts.service.Name, + subject: pair.source.ID(), + source: pair.source.Name(), + view: view, + side: "projected", + } + validator, err := facts.names.declareDependentForAPI(viewsPackage, id, declaration.Declaration(), "Validate", suffix, facts.apiName) + if err != nil { + return err + } + facts.validators[key] = validator + validation.declaration = validator + } + if _, ok := pair.projected.(*expr.ResultTypeExpr); ok { + projectedFacts.mapDeclaration, err = facts.names.declareForAPI(viewsPackage, serviceSymbolID{ + role: serviceViewMapNameRole, + service: facts.service.Name, + subject: pair.source.ID(), + source: pair.source.Name(), + }, codegen.Goify(pair.source.Name(), true)+"Map", facts.apiName) + if err != nil { + return err + } + } + for _, conversion := range projectedFacts.conversions { + side := "to-projected" + preferredBase := codegen.Goify(pair.projected.Name(), true) + if conversion.toResult { + side = "to-result" + preferredBase = codegen.Goify(pair.source.Name(), true) + } + suffix := "" + if conversion.viewName != expr.DefaultView { + suffix = codegen.Goify(conversion.viewName, true) + } + conversion.constructor, err = facts.names.declareForAPI(servicePackage, serviceSymbolID{ + role: servicePrivateProjectionConstructorNameRole, + service: facts.service.Name, + subject: pair.source.ID(), + source: pair.source.Name(), + view: canonicalValidatorView(conversion.viewName), + side: side, + }, "new"+preferredBase+suffix, facts.apiName) + if err != nil { + return err + } + if conversion.plan == nil { + continue + } + for _, helper := range conversion.plan.Helpers() { + sourceName, sourceID := transformDataTypeName(helper.Source.Type) + targetName, targetID := transformDataTypeName(helper.Target.Type) + sourcePreferred := sourceName + targetPreferred := targetName + viewsPackageName := strings.ToLower(codegen.Goify(facts.service.Name, false)) + "views" + if conversion.toResult { + sourcePreferred = viewsPackageName + codegen.Goify(sourceName, true) + } else { + targetPreferred = viewsPackageName + codegen.Goify(targetName, true) + } + declaration, err := facts.names.declareForAPI(servicePackage, serviceSymbolID{ + role: serviceTransformHelperNameRole, + service: facts.service.Name, + subject: pair.source.ID(), + view: canonicalValidatorView(conversion.viewName), + source: sourceID, + target: targetID, + side: side, + occurrence: helper.Occurrence, + required: helper.Required, + }, "transform"+codegen.Goify(sourcePreferred, true)+"To"+codegen.Goify(targetPreferred, true), facts.apiName) + if err != nil { + return err + } + if err := conversion.plan.BindHelperDeclaration(helper.ID, declaration); err != nil { + return err + } + } + } + } + resultType, hasViews := method.Result.Type.(*expr.ResultTypeExpr) + if !hasViews { + continue + } + for _, projected := range projection.types { + if projected.pair.source.Origin() == resultType.Origin() { + facts.methodByExpr[method].viewedResult.conversions = projected.conversions + break + } + } + viewedDeclaration, err := viewsPackage.DerivedType(codegen.NewViewedResultTypeID(resultType)) + if err != nil { + return err + } + facts.methodByExpr[method].viewedResult.declaration = viewedDeclaration + viewedValidatorKey := validatorKey{declaration: viewedDeclaration} + if facts.validators[viewedValidatorKey] == nil { + validatorID := serviceSymbolID{ + role: serviceValidatorNameRole, + service: facts.service.Name, + method: facts.methodByExpr[method].varName, + subject: resultType.ID(), + source: resultType.Name(), + side: "viewed", + } + validator, err := facts.names.declareDependentForAPI(viewsPackage, validatorID, viewedDeclaration.Declaration(), "Validate", "", facts.apiName) + if err != nil { + return err + } + facts.validators[viewedValidatorKey] = validator + } + facts.methodByExpr[method].viewedResult.validator = facts.validators[viewedValidatorKey] + for _, symbol := range []struct { + role serviceNameRole + prefix string + side string + }{ + {serviceViewConstructorNameRole, "NewViewed", "to-viewed"}, + {serviceViewConstructorNameRole, "New", "to-result"}, + } { + constructor, err := facts.names.declareForAPI(servicePackage, serviceSymbolID{ + role: symbol.role, + service: facts.service.Name, + subject: resultType.ID(), + source: resultType.Name(), + side: symbol.side, + }, symbol.prefix+codegen.Goify(resultType.Name(), true), facts.apiName) + if err != nil { + return err + } + if symbol.side == "to-viewed" { + facts.methodByExpr[method].viewedResult.toViewed = constructor + } else { + facts.methodByExpr[method].viewedResult.toResult = constructor + } + } + facts.methodByExpr[method].viewedResult.mapDeclaration, err = facts.names.declareForAPI(viewsPackage, serviceSymbolID{ + role: serviceViewMapNameRole, + service: facts.service.Name, + subject: resultType.ID(), + source: resultType.Name(), + }, codegen.Goify(resultType.Name(), true)+"Map", facts.apiName) + if err != nil { + return err + } + viewedFacts := facts.methodByExpr[method].viewedResult + for _, view := range viewedFacts.views { + declaration := facts.validators[validatorKey{ + declaration: viewedFacts.projected.declaration, + view: canonicalValidatorView(view.name), + }] + viewedFacts.validationCalls = append(viewedFacts.validationCalls, declaration) + } + } + linkViewConversionCalls(facts) + return planServiceValidations(facts, rootTypes, generation) +} + +// linkViewConversionCalls gives collection and child constructor calls the Go +// function names chosen for their result type and view. +func linkViewConversionCalls(facts *serviceFacts) { + lookup := make(map[viewConversionCallKey]*codegen.NameDeclaration) + for _, method := range facts.methods { + projection := facts.projections[method] + if projection == nil { + continue + } + for _, projected := range projection.types { + for _, conversion := range projected.conversions { + origin := projected.pair.projected.Origin() + if conversion.toResult { + origin = projected.pair.source.Origin() + } + lookup[viewConversionCallKey{ + origin: origin, + view: canonicalValidatorView(conversion.viewName), + toResult: conversion.toResult, + }] = conversion.constructor + } + } + } + for _, method := range facts.methods { + projection := facts.projections[method] + if projection == nil { + continue + } + for _, projected := range projection.types { + for _, conversion := range projected.conversions { + collection := projected.pair.projected + if conversion.toResult { + collection = projected.pair.source + } + if array := expr.AsArray(collection); array != nil { + userType := array.ElemType.Type.(expr.UserType) + conversion.elementCall = lookup[viewConversionCallKey{ + origin: userType.Origin(), + view: canonicalValidatorView(conversion.viewName), + toResult: conversion.toResult, + }] + } + for _, field := range conversion.fields { + userType := field.attribute.Type.(expr.UserType) + field.call = lookup[viewConversionCallKey{ + origin: userType.Origin(), + view: canonicalValidatorView(field.view), + toResult: conversion.toResult, + }] + } + } + } + } +} + +// interceptorHasStreamingAccess reports whether interceptor causes a wrapped +// stream implementation to be emitted. +func interceptorHasStreamingAccess(interceptor *expr.InterceptorExpr) bool { + return interceptor.ReadStreamingPayload != nil || interceptor.WriteStreamingPayload != nil || + interceptor.ReadStreamingResult != nil || interceptor.WriteStreamingResult != nil +} + +// interceptorNamed reports whether interceptors contains name. +func interceptorNamed(interceptors []*expr.InterceptorExpr, name string) bool { + for _, interceptor := range interceptors { + if interceptor.Name == name { + return true + } + } + return false +} diff --git a/codegen/service/plan_lifecycle.go b/codegen/service/plan_lifecycle.go new file mode 100644 index 0000000000..b1d80dc791 --- /dev/null +++ b/codegen/service/plan_lifecycle.go @@ -0,0 +1,300 @@ +// This file prepares every service design used by one run. It rejects missing +// or repeated designs and chooses each shared Go name once. +package service + +import ( + "fmt" + "path" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // HTTPMethodNames contains the Go names used by one service method in an HTTP + // package. The HTTP generator reuses these names instead of choosing new ones. + HTTPMethodNames struct { + // Method is the name used for the service endpoint field and receiver method. + Method string + // ServerStream is the service's server stream type name. + ServerStream string + // ClientStream is the service's client stream type name. + ClientStream string + } +) + +// NewPlans reads every service design in generation. It returns the data used +// to write each service and chooses shared Go declaration names once. +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + owned := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + if root, ok := candidate.(*expr.RootExpr); ok { + owned[root] = struct{}{} + } + } + seen := make(map[*expr.RootExpr]struct{}, len(inputs)) + for _, input := range inputs { + if _, ok := owned[input.Root]; !ok { + return nil, rootMembershipError(input.Root) + } + if _, ok := seen[input.Root]; ok { + return nil, fmt.Errorf("service root %p is planned more than once", input.Root) + } + seen[input.Root] = struct{}{} + } + if len(inputs) != len(owned) { + return nil, fmt.Errorf( + "service planning requires all %d generation roots, got %d", + len(owned), + len(inputs), + ) + } + servicePaths, err := allocateServicePackagePaths(generation.GenPkg(), inputs) + if err != nil { + return nil, err + } + plans := make([]*Plan, len(inputs)) + for index, input := range inputs { + facts, err := collectRootFacts(input.Root, generation, input.Examples, servicePaths) + if err != nil { + return nil, err + } + plans[index] = &Plan{generation: generation, facts: facts} + } + allFacts := make([]*rootFacts, len(plans)) + for index, plan := range plans { + allFacts[index] = plan.facts + } + if err := collectGeneratedPackageEmissions(allFacts); err != nil { + return nil, err + } + if err := collectExternalConversions(allFacts, generation); err != nil { + return nil, err + } + return plans, nil +} + +// NewPlan reads the only service design in generation. Call NewPlans when a run +// contains several designs so shared files and methods receive names only once. +func NewPlan(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator) (*Plan, error) { + plans, err := NewPlans(generation, PlanInput{Root: root, Examples: examples}) + if err != nil { + return nil, err + } + return plans[0], nil +} + +// Root returns the service design used by this plan. Other file writers use it +// to reject a plan created for a different design. +func (p *Plan) Root() *expr.RootExpr { + return p.facts.root +} + +// ExampleImports returns copies of the application and interceptor imports +// selected while this service plan was created. +func (p *Plan) ExampleImports() []*codegen.ImportSpec { + imports := make([]*codegen.ImportSpec, len(p.facts.exampleImports)) + for index, spec := range p.facts.exampleImports { + copy := *spec + imports[index] = © + } + return imports +} + +// ProjectedResult returns a copy of the result fields included in the views for +// method. It reports an error when method is absent or has no views. +func (p *Plan) ProjectedResult(method *expr.MethodExpr) (*expr.AttributeExpr, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + if facts.viewedResult == nil { + return nil, fmt.Errorf("service method %q does not have a viewed result", method.Name) + } + projected := expr.AsObject(facts.viewedResult.wrapped.Attribute().Type).Attribute("projected") + return expr.DupAtt(projected), nil + } + if method == nil { + return nil, fmt.Errorf("service method is not part of this plan") + } + return nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// HTTPMethodNames returns the Go names already chosen for method. It returns an +// error when the service design does not contain method. +func (p *Plan) HTTPMethodNames(method *expr.MethodExpr) (HTTPMethodNames, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + return HTTPMethodNames{ + Method: facts.varName, + ServerStream: facts.serverStreamVarName, + ClientStream: facts.clientStreamVarName, + }, nil + } + if method == nil { + return HTTPMethodNames{}, fmt.Errorf("service method is not part of this plan") + } + return HTTPMethodNames{}, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// MethodPayloadLayout returns the Go fields stored by method's payload. For a +// named payload, it returns the definition containing those fields instead of +// the outer reference to the named type. +func (p *Plan) MethodPayloadLayout(method *expr.MethodExpr) (*codegen.GoTypePlan, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + if facts.payload == nil || facts.payload.layout == nil { + return nil, fmt.Errorf("service method %q does not have a payload", method.Name) + } + if facts.payload.definition != nil { + return facts.payload.definition, nil + } + return facts.payload.layout, nil + } + if method == nil { + return nil, fmt.Errorf("service method is not part of this plan") + } + return nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// StreamingResultLayout returns the Go type layout used by method's stream. +// An explicitly empty streaming result uses the ordinary result layout, which +// is the type implemented by the generated client and server stream methods. +// Transport planners use this fact to decide whether their decoded value is +// directly assignable or needs a generated conversion. +func (p *Plan) StreamingResultLayout(method *expr.MethodExpr) (*codegen.GoTypePlan, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + if facts.streamingResult != nil && facts.streamingResult.present { + return facts.streamingResult.layout, nil + } + if facts.result == nil || facts.result.layout == nil { + return nil, fmt.Errorf("service method %q does not have a streaming result", method.Name) + } + return facts.result.layout, nil + } + if method == nil { + return nil, fmt.Errorf("service method is not part of this plan") + } + return nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// ServicePackageImports returns the generated service and views package +// preferences recorded before Generation.Freeze. Transport generators use it +// for service-level files that may not contain a method, such as an HTTP file +// server. +func (p *Plan) ServicePackageImports( + serviceExpression *expr.ServiceExpr, +) (servicePackage, viewsPackage *codegen.ImportSpec, err error) { + for _, service := range p.facts.services { + if service.service != serviceExpression { + continue + } + serviceCopy := *service.packageImport + viewsCopy := *service.viewsImport + return &serviceCopy, &viewsCopy, nil + } + if serviceExpression == nil { + return nil, nil, fmt.Errorf("service is not part of this plan") + } + return nil, nil, fmt.Errorf("service %q is not part of this plan", serviceExpression.Name) +} + +// MethodPackageImports returns the generated service package preference and, +// for a viewed result, its views package preference. These are the names and +// paths recorded before Generation.Freeze; an importing output package may +// receive a numbered qualifier when another import requests the same name. +func (p *Plan) MethodPackageImports( + method *expr.MethodExpr, +) (servicePackage, viewsPackage *codegen.ImportSpec, err error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + serviceCopy := *service.packageImport + if facts.viewedResult == nil { + return &serviceCopy, nil, nil + } + viewsCopy := *service.viewsImport + return &serviceCopy, &viewsCopy, nil + } + if method == nil { + return nil, nil, fmt.Errorf("service method is not part of this plan") + } + return nil, nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// collectRootFacts reads one service design and chooses names used only by that +// design before shared files receive their names. +func collectRootFacts(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator, servicePaths map[string]string) (*rootFacts, error) { + examplePackageScope := codegen.NewNameScope() + for _, service := range root.Services { + examplePackageScope.Unique(strings.ToLower(codegen.Goify(service.Name, false))) + } + facts := &rootFacts{ + root: root, + apiName: root.API.Name, + apiVersion: root.API.Version, + examplePackageName: examplePackageScope.Unique(strings.ToLower(codegen.Goify(root.API.Name, false)), "api"), + serviceByID: make(map[string]*serviceFacts, len(root.Services)), + types: append([]expr.UserType(nil), root.Types...), + rootTypes: newRootTypeSet(root), + examples: examples, + } + for _, service := range root.Services { + serviceFacts := collectServiceFacts(root, service, examples) + serviceFacts.packagePath = servicePaths[service.Name] + serviceFacts.viewsPath = serviceFacts.packagePath + "/views" + serviceFacts.packageImport = codegen.NewImport( + strings.ToLower(codegen.Goify(service.Name, false)), + serviceFacts.packagePath, + ) + serviceFacts.viewsImport = codegen.NewImport( + serviceFacts.packageImport.Name+"views", + serviceFacts.viewsPath, + ) + facts.services = append(facts.services, serviceFacts) + facts.serviceByID[service.Name] = serviceFacts + } + rootPath := path.Dir(generation.GenPkg()) + facts.exampleImports = append(facts.exampleImports, codegen.NewImport(facts.examplePackageName, rootPath)) + for _, service := range facts.services { + if len(service.serverInterceptors) > 0 || len(service.clientInterceptors) > 0 { + facts.exampleImports = append(facts.exampleImports, codegen.NewImport("interceptors", rootPath+"/interceptors")) + break + } + } + if err := collectServiceDeclarations(facts, generation); err != nil { + return nil, err + } + for _, serviceFacts := range facts.services { + if err := collectServiceNames(serviceFacts, facts.rootTypes, generation); err != nil { + return nil, err + } + if err := collectServiceTypeFacts(serviceFacts, facts.types, facts.rootTypes, generation); err != nil { + return nil, err + } + if err := collectServiceUnionFacts(serviceFacts, facts.rootTypes, generation); err != nil { + return nil, err + } + if err := planServiceTypeLayouts(serviceFacts, facts.rootTypes, generation); err != nil { + return nil, err + } + if err := planServiceFileImports(serviceFacts, facts.rootTypes, generation); err != nil { + return nil, err + } + } + return facts, nil +} diff --git a/codegen/service/projected_result_test.go b/codegen/service/projected_result_test.go new file mode 100644 index 0000000000..4363ebed01 --- /dev/null +++ b/codegen/service/projected_result_test.go @@ -0,0 +1,76 @@ +// This file checks that HTTP generation can copy the result fields selected by +// a view before names are assigned to the service package. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestPlanProjectedResultBeforeLink(t *testing.T) { + var viewed, plain *expr.MethodExpr + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.projected-result", func() { + dsl.TypeName("ProjectedResult") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { dsl.Attribute("name") }) + dsl.View("summary", func() { dsl.Attribute("name") }) + }) + dsl.Service("Values", func() { + viewed = dsl.Method("Viewed", func() { dsl.Result(result) }) + plain = dsl.Method("Plain", func() { dsl.Result(dsl.String) }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + + first, err := plan.ProjectedResult(viewed) + require.NoError(t, err) + first.Description = "changed by caller" + second, err := plan.ProjectedResult(viewed) + require.NoError(t, err) + require.NotEqual(t, first.Description, second.Description) + require.NotSame(t, first, second) + + _, err = plan.ProjectedResult(plain) + require.EqualError(t, err, `service method "Plain" does not have a viewed result`) + foreign := &expr.MethodExpr{Name: "Foreign"} + _, err = plan.ProjectedResult(foreign) + require.EqualError(t, err, `service method "Foreign" is not part of this plan`) + + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + linked := plan.Services().Get("Values").Method("Viewed").ViewedResult + require.NotNil(t, linked) + require.NotEqual(t, "changed by caller", expr.AsObject(linked.Type).Attribute("projected").Description) +} + +func TestPlanHTTPMethodNamesBeforeLink(t *testing.T) { + var watch *expr.MethodExpr + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + watch = dsl.Method("Watch", func() { + dsl.StreamingResult(dsl.String) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + + names, err := plan.HTTPMethodNames(watch) + require.NoError(t, err) + require.Equal(t, "Watch", names.Method) + require.Equal(t, "WatchServerStream", names.ServerStream) + require.Equal(t, "WatchClientStream", names.ClientStream) + + _, err = plan.HTTPMethodNames(&expr.MethodExpr{Name: "Foreign"}) + require.EqualError(t, err, `service method "Foreign" is not part of this plan`) +} diff --git a/codegen/service/render_name_compatibility_test.go b/codegen/service/render_name_compatibility_test.go new file mode 100644 index 0000000000..b3c4aaca6a --- /dev/null +++ b/codegen/service/render_name_compatibility_test.go @@ -0,0 +1,106 @@ +// This file verifies that plugins still receive the released Go-name strings +// while Goa templates use the matching planned declarations. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +func TestLinkedRenderNamesMatchDeclarations(t *testing.T) { + root := codegen.RunDSL(t, func() { + customError := dsl.Type("CustomError", func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + reading := dsl.ResultType("application/vnd.reading", func() { + dsl.TypeName("Reading") + dsl.Attribute("value", dsl.String, func() { + dsl.MinLength(1) + }) + dsl.Required("value") + dsl.View("default", func() { + dsl.Attribute("value") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(reading) + dsl.Error("failed") + dsl.Error("custom", customError) + }) + }) + }) + + data := mustServicePlan(t, root).Services().Get("Values") + endpoints := endpointData(data) + require.Equal(t, endpoints.EndpointsDeclaration.Name(), endpoints.VarName) + require.Equal(t, endpoints.ClientDeclaration.Name(), endpoints.ClientVarName) + require.Equal(t, endpoints.ServiceDeclaration.Name(), endpoints.ServiceVarName) + for _, method := range endpoints.Methods { + require.Equal(t, method.ClientDeclaration.Name(), method.ClientVarName) + require.Equal(t, method.ServiceDeclaration.Name(), method.ServiceVarName) + } + require.Len(t, data.errorInits, 1) + require.Equal(t, "MakeFailed", data.errorInits[0].Name) + assertErrorInitName(t, data.errorInits[0]) + + method := data.Method("Read") + require.NotEmpty(t, data.ViewsPkg) + require.Len(t, method.Errors, 2) + for _, serviceError := range method.Errors { + switch serviceError.ErrName { + case "failed": + assertErrorInitName(t, serviceError) + case "custom": + require.Nil(t, serviceError.Declaration) + require.Empty(t, serviceError.Name) + default: + t.Errorf("unexpected service error %q", serviceError.ErrName) + } + } + require.NotNil(t, method.ViewedResult) + require.Equal(t, "NewViewedReading", method.ViewedResult.Init.Name) + require.Equal(t, "ValidateReading", method.ViewedResult.Validate.Name) + assertInitName(t, method.ViewedResult.Init) + assertInitName(t, method.ViewedResult.ResultInit) + assertValidateName(t, method.ViewedResult.Validate) + + for _, projected := range data.projectedTypes { + require.Equal(t, data.ViewsPkg, projected.ViewsPkg) + for _, init := range projected.Projections { + assertInitName(t, init) + } + for _, init := range projected.TypeInits { + assertInitName(t, init) + } + for _, validation := range projected.Validations { + assertValidateName(t, validation) + } + } +} + +// assertErrorInitName checks the compatibility name exposed to plugins. +func assertErrorInitName(t *testing.T, data *ErrorInitData) { + t.Helper() + require.NotEmpty(t, data.Name) + require.Equal(t, data.Declaration.Name(), data.Name) +} + +// assertInitName checks the compatibility name exposed to plugins. +func assertInitName(t *testing.T, data *InitData) { + t.Helper() + require.NotEmpty(t, data.Name) + require.Equal(t, data.Declaration.Name(), data.Name) +} + +// assertValidateName checks the compatibility name exposed to plugins. +func assertValidateName(t *testing.T, data *ValidateData) { + t.Helper() + require.NotEmpty(t, data.Name) + require.Equal(t, data.Declaration.Name(), data.Name) +} diff --git a/codegen/service/retained_expression_mutation_contract_test.go b/codegen/service/retained_expression_mutation_contract_test.go new file mode 100644 index 0000000000..a61df98dd0 --- /dev/null +++ b/codegen/service/retained_expression_mutation_contract_test.go @@ -0,0 +1,167 @@ +// This file proves service rendering uses only facts collected before the +// generation freezes, even if callers later mutate the evaluated expressions. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type retainedExpressionFixture struct { + root *expr.RootExpr + service *expr.ServiceExpr + method *expr.MethodExpr + result *expr.ResultTypeExpr + interceptor *expr.InterceptorExpr +} + +// TestServicePlanIgnoresRetainedExpressionMutation catches linking and +// rendering that reread mutable service, method, view, error, interceptor, +// security, example, or stream expressions after NewPlan returns. +func TestServicePlanIgnoresRetainedExpressionMutation(t *testing.T) { + baselineFixture := retainedExpressionMutationFixture(t) + baselinePlan := retainedServicePlanForPackage(t, baselineFixture.root) + baseline := renderedPlanAndExamples(t, baselinePlan) + baselineMethod := baselinePlan.Services().Get("RetainedMutable").Methods[0] + + tests := []struct { + name string + mutate func(*retainedExpressionFixture) + }{ + {"service and method", func(f *retainedExpressionFixture) { + f.service.Name = "MutatedService" + f.service.Description = "mutated service" + f.method.Name = "MutatedMethod" + f.method.Description = "mutated method" + f.method.Idempotent = !f.method.Idempotent + }}, + {"errors", func(f *retainedExpressionFixture) { + f.service.Errors[0].Description = "mutated service error" + f.method.Errors[0].Description = "mutated method error" + f.method.Errors[0].Meta = expr.MetaExpr{"goa:error:fault": nil} + }}, + {"interceptor", func(f *retainedExpressionFixture) { + f.interceptor.Description = "mutated interceptor" + f.interceptor.ReadPayload = nil + f.interceptor.ReadStreamingPayload = nil + }}, + {"security", func(f *retainedExpressionFixture) { + f.method.Requirements[0].Scopes[0] = "mutated" + f.method.Requirements[0].Schemes[0].Scopes[0].Name = "mutated" + }}, + {"examples", func(f *retainedExpressionFixture) { + f.method.Payload.UserExamples[0].Value = map[string]any{"key": "mutated"} + f.method.StreamingPayload.UserExamples[0].Value = map[string]any{"chunk": "mutated"} + }}, + {"stream", func(f *retainedExpressionFixture) { + f.method.Stream = expr.NoStreamKind + f.method.StreamingPayload.Description = "mutated streaming payload" + f.method.StreamingResult.Description = "mutated streaming result" + }}, + {"type layout", func(f *retainedExpressionFixture) { + field := expr.AsObject(f.method.Payload.Type).Attribute("key") + field.Description = "mutated field" + field.Meta = expr.MetaExpr{"struct:field:name": []string{"MutatedKey"}} + }}, + {"result and view", func(f *retainedExpressionFixture) { + f.method.Result.Description = "mutated result" + f.result.Views[0].Description = "mutated view" + viewObject := expr.AsObject(f.result.Views[0].Type) + viewObject.Set("extra", &expr.AttributeExpr{Type: expr.String}) + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := retainedExpressionMutationFixture(t) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{fixture.root}) + plan, err := NewPlan(fixture.root, generation, expr.NewExampleGenerator(fixture.root.API.RandomizerFactory)) + require.NoError(t, err) + test.mutate(fixture) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + requireRenderedServiceFilesEqual(t, baseline, renderedPlanAndExamples(t, plan)) + + method := plan.Services().Get("RetainedMutable").Methods[0] + require.Equal(t, baselineMethod.PayloadEx, method.PayloadEx) + require.Equal(t, baselineMethod.StreamingPayloadEx, method.StreamingPayloadEx) + }) + } +} + +// retainedExpressionMutationFixture builds one service that exercises every +// expression family the retained core service plan must finish collecting. +func retainedExpressionMutationFixture(t *testing.T) *retainedExpressionFixture { + t.Helper() + fixture := new(retainedExpressionFixture) + fixture.root = codegen.RunDSL(t, func() { + auth := dsl.APIKeySecurity("key", func() { + dsl.Scope("read", "Read values") + }) + fixture.interceptor = dsl.Interceptor("Audit", func() { + dsl.Description("Audits request values.") + dsl.ReadPayload(func() { dsl.Attribute("key") }) + dsl.ReadStreamingPayload(func() { dsl.Attribute("chunk") }) + }) + result := dsl.ResultType("application/vnd.retained", func() { + dsl.TypeName("RetainedResult") + dsl.Description("The retained result.") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { dsl.Attribute("value") }) + dsl.View("summary", func() { dsl.Attribute("value") }) + }) + fixture.result = result + streamResult := dsl.Type("RetainedStreamResult", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + serviceError := dsl.Type("RetainedServiceError", func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + methodError := dsl.Type("RetainedMethodError", func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + fixture.service = dsl.Service("RetainedMutable", func() { + dsl.Description("The retained mutable service.") + dsl.Security(auth, func() { dsl.Scope("read") }) + dsl.ServerInterceptor(fixture.interceptor) + dsl.ClientInterceptor(fixture.interceptor) + dsl.Error("service_failed", serviceError, "The service failed.") + fixture.method = dsl.Method("Watch", func() { + dsl.Description("Watches retained values.") + dsl.Payload(func() { + dsl.APIKey("key", "key", dsl.String) + dsl.Required("key") + dsl.Example(map[string]any{"key": "original"}) + }) + dsl.StreamingPayload(func() { + dsl.Attribute("chunk", dsl.String) + dsl.Required("chunk") + dsl.Example(map[string]any{"chunk": "original"}) + }) + dsl.Result(result) + dsl.StreamingResult(streamResult) + dsl.Error("method_failed", methodError, "The method failed.") + }) + }) + }) + return fixture +} + +// renderedPlanAndExamples renders both generated service packages and their +// starter implementation so post-link expression reads cannot hide in either. +func renderedPlanAndExamples(t *testing.T, plan *Plan) map[string][]byte { + t.Helper() + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + return renderedServiceFiles(t, files) +} diff --git a/codegen/service/retained_plan_test.go b/codegen/service/retained_plan_test.go new file mode 100644 index 0000000000..455832b4b8 --- /dev/null +++ b/codegen/service/retained_plan_test.go @@ -0,0 +1,93 @@ +// This file verifies that service planning retains one immutable render model +// per design root. Definitions and references must consume the exact package- +// owned declaration record collected by that plan. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestServicePlanSharesDefinitionAndReferenceDeclaration catches service +// analysis that reconstructs a payload name independently from its definition. +func TestServicePlanSharesDefinitionAndReferenceDeclaration(t *testing.T) { + var payload expr.UserType + root := codegen.RunDSL(t, func() { + payload = dsl.Type("Payload", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + root.Service("Values").Methods = nil + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + + owner := generation.Package("generated.local/gen/values") + declaration, err := owner.UserType(payload) + require.NoError(t, err) + services := plan.Services() + require.Len(t, services.Get("Values").Methods, 1) + require.Same(t, declaration, services.Get("Values").Methods[0].PayloadDeclaration) +} + +// TestServicePlanSharesNestedValidatorDeclaration verifies that a projected +// parent call and the child function definition retain one package declaration +// even when another projected type collides with the child's preferred +// validator name. +func TestServicePlanSharesNestedValidatorDeclaration(t *testing.T) { + root := codegen.RunDSL(t, func() { + child := dsl.ResultType("application/vnd.child", func() { + dsl.TypeName("Child") + dsl.Attribute("name", dsl.String) + dsl.Required("name") + }) + collision := dsl.Type("ValidateChild", func() { + dsl.Attribute("value", dsl.String) + }) + parent := dsl.ResultType("application/vnd.parent", func() { + dsl.TypeName("Parent") + dsl.Attribute("child", child) + dsl.Attribute("collision", collision) + dsl.Required("child") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(parent) + }) + }) + }) + + plan := mustServicePlan(t, root) + data := plan.Services().Get("Values") + var child, parent *ValidateData + for _, projected := range data.projectedTypes { + for _, validation := range projected.Validations { + switch projected.Name { + case "ChildView": + child = validation + case "ParentView": + parent = validation + } + } + } + require.NotNil(t, child) + require.NotNil(t, parent) + require.Len(t, parent.Calls, 1) + require.Same(t, child.Declaration, parent.Calls[0].Declaration) + require.Equal(t, child.Declaration.Name(), parent.Calls[0].Declaration.Name()) +} diff --git a/codegen/service/security_data.go b/codegen/service/security_data.go new file mode 100644 index 0000000000..1ac7a9249d --- /dev/null +++ b/codegen/service/security_data.go @@ -0,0 +1,78 @@ +// This file formats evaluated security schemes and authorization attributes for service templates. +package service + +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// BuildSchemeData builds the scheme data for the given scheme and method expr. +func BuildSchemeData(s *expr.SchemeExpr, m *expr.MethodExpr) *SchemeData { + if !expr.IsObject(m.Payload.Type) { + return nil + } + if s.Kind == expr.BasicAuthKind { + userAtt := expr.TaggedAttribute(m.Payload, "security:username") + passAtt := expr.TaggedAttribute(m.Payload, "security:password") + return &SchemeData{ + Type: s.Kind.String(), + SchemeName: s.SchemeName, + UsernameAttr: userAtt, + UsernameField: codegen.Goify(userAtt, true), + UsernamePointer: m.Payload.IsPrimitivePointer(userAtt, true), + UsernameRequired: m.Payload.IsRequired(userAtt), + PasswordAttr: passAtt, + PasswordField: codegen.Goify(passAtt, true), + PasswordPointer: m.Payload.IsPrimitivePointer(passAtt, true), + PasswordRequired: m.Payload.IsRequired(passAtt), + Scopes: schemeScopes(s), + } + } + // The remaining scheme kinds all carry a single credential attribute + // identified by a kind-specific security tag on the method payload. + var tag string + switch s.Kind { + case expr.APIKeyKind: + tag = "security:apikey:" + s.SchemeName + case expr.BearerKind: + tag = "security:bearer" + case expr.JWTKind: + tag = "security:token" + case expr.OAuth2Kind: + tag = "security:accesstoken" + default: + return nil + } + keyAtt := expr.TaggedAttribute(m.Payload, tag) + if keyAtt == "" { + return nil + } + data := &SchemeData{ + Type: s.Kind.String(), + Name: s.Name, + SchemeName: s.SchemeName, + CredField: codegen.Goify(keyAtt, true), + CredPointer: m.Payload.IsPrimitivePointer(keyAtt, true), + CredRequired: m.Payload.IsRequired(keyAtt), + KeyAttr: keyAtt, + Scopes: schemeScopes(s), + In: s.In, + } + if s.Kind == expr.OAuth2Kind { + data.Flows = s.Flows + } + return data +} + +// schemeScopes returns the authorization scope names defined by the scheme. It +// returns nil when the scheme defines none. +func schemeScopes(s *expr.SchemeExpr) []string { + if len(s.Scopes) == 0 { + return nil + } + scopes := make([]string, len(s.Scopes)) + for i, sc := range s.Scopes { + scopes[i] = sc.Name + } + return scopes +} diff --git a/codegen/service/security_test.go b/codegen/service/security_test.go index 0ed2ab5e5b..96921351cc 100644 --- a/codegen/service/security_test.go +++ b/codegen/service/security_test.go @@ -24,9 +24,9 @@ func TestSecureEndpointInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := EndpointFile("", root.Services[0], services) + fs := endpointFile(plan, plan.facts.services[0]) require.NotNil(t, fs) sections := fs.SectionTemplates require.Greater(t, len(sections), 1) @@ -51,9 +51,9 @@ func TestSecureEndpoint(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := EndpointFile("", root.Services[0], services) + fs := endpointFile(plan, plan.facts.services[0]) require.NotNil(t, fs) sections := fs.SectionTemplates code := codegen.SectionCode(t, sections[4]) @@ -73,9 +73,9 @@ func TestSecureWithSkipRequestBodyEncodeDecode(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := EndpointFile("", root.Services[0], services) + fs := endpointFile(plan, plan.facts.services[0]) require.NotNil(t, fs) sections := fs.SectionTemplates code := codegen.SectionCode(t, sections[5]) diff --git a/codegen/service/service.go b/codegen/service/service.go index ba6ed8928c..0f610cc6fe 100644 --- a/codegen/service/service.go +++ b/codegen/service/service.go @@ -1,8 +1,13 @@ +// This file renders service declarations and groups declarations with explicit +// package locations into the generated Go package and file where each one is +// written. package service import ( "fmt" + "path" "path/filepath" + "slices" "sort" "strings" @@ -10,52 +15,133 @@ import ( "goa.design/goa/v3/expr" ) -// Files returns the generated files for the given service as well as a map -// indexing user type names by custom path as defined by the "struct:pkg:path" -// metadata. The map is built over each invocation of Files to avoid duplicate -// type definitions. -func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, userTypePkgs map[string][]string) []*codegen.File { - svc := services.Get(service.Name) +type ( + // serviceTypeSectionPhase identifies the stable group containing a service + // type-file section. Type declarations must precede methods defined on them. + serviceTypeSectionPhase uint8 + + // serviceTypeSection retains the explicit ordering facts for one section in + // service.go instead of encoding its group in a decorated string key. + serviceTypeSection struct { + phase serviceTypeSectionPhase + name string + section *codegen.SectionTemplate + } +) + +const ( + serviceTypeDefinitionPhase serviceTypeSectionPhase = iota + serviceErrorImplementationPhase +) + +// Files renders every service file described by plans. Each plan must be +// linked so every renderer reads the declarations copied before their names +// were chosen instead of rebuilding service analysis from the expression root. +func Files(plans ...*Plan) ([]*codegen.File, error) { + var files []*codegen.File + if len(plans) == 0 { + return files, nil + } + generation := plans[0].generation + ownedRoots := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + if root, ok := candidate.(*expr.RootExpr); ok { + ownedRoots[root] = struct{}{} + } + } + for _, plan := range plans[1:] { + if plan.generation != generation { + return nil, fmt.Errorf("service plans belong to different generations") + } + } + if len(plans) != len(ownedRoots) { + return nil, fmt.Errorf("service rendering requires all %d planned roots, got %d", len(ownedRoots), len(plans)) + } + seenRoots := make(map[*expr.RootExpr]struct{}, len(plans)) + for _, plan := range plans { + if _, owned := ownedRoots[plan.facts.root]; !owned { + return nil, rootMembershipError(plan.facts.root) + } + if _, exists := seenRoots[plan.facts.root]; exists { + return nil, fmt.Errorf("service root %p is rendered more than once", plan.facts.root) + } + seenRoots[plan.facts.root] = struct{}{} + } + analyses := make([]*ServicesData, len(plans)) + for index, plan := range plans { + analyses[index] = plan.Services() + for _, facts := range plan.facts.services { + files = append(files, serviceFiles(plan, facts)...) + } + } + generatedFiles, err := generatedPackageFiles(analyses) + if err != nil { + return nil, err + } + files = append(files, generatedFiles...) + for _, plan := range plans { + for _, facts := range plan.facts.services { + files = append(files, + endpointFile(plan, facts), + clientFile(plan, facts), + ) + if file := viewsFile(plan, facts); file != nil { + files = append(files, file) + } + } + } + conversionFiles, err := externalConversionFiles(plans) + if err != nil { + return nil, err + } + files = append(files, convertFiles(conversionFiles)...) + return files, nil +} + +// serviceFiles renders the declarations and helpers owned exclusively by one +// service package. Relocated declarations and all union definitions are +// emitted later by generatedPackageFiles. +func serviceFiles(plan *Plan, facts *serviceFacts) []*codegen.File { + services := plan.Services() + svc := services.Get(facts.name) svcName := svc.PathName svcPath := filepath.Join(codegen.Gendir, svcName, "service.go") seen := make(map[string]struct{}) - typeDefSections := make(map[string]map[string]*codegen.SectionTemplate) - typesByPath := make(map[string][]string) + typeSections := make([]serviceTypeSection, 0) svcSections := make([]*codegen.SectionTemplate, 0, 10) - addTypeDefSection := func(path, name string, section *codegen.SectionTemplate) { - if typeDefSections[path] == nil { - typeDefSections[path] = make(map[string]*codegen.SectionTemplate) - } - typeDefSections[path][name] = section - typesByPath[path] = append(typesByPath[path], name) + addTypeDefSection := func(name string, section *codegen.SectionTemplate) { + typeSections = append(typeSections, serviceTypeSection{ + phase: serviceTypeDefinitionPhase, + name: name, + section: section, + }) seen[name] = struct{}{} } - for _, m := range svc.Methods { - payloadPath := pathWithDefault(m.PayloadLoc, svcPath) - resultPath := pathWithDefault(m.ResultLoc, svcPath) - if m.PayloadDef != "" { + for i, m := range svc.Methods { + method := facts.orderedMethods[i] + if m.PayloadLoc == nil && m.PayloadDef != "" { if _, ok := seen[m.Payload]; !ok { - addTypeDefSection(payloadPath, m.Payload, &codegen.SectionTemplate{ + addTypeDefSection(m.Payload, &codegen.SectionTemplate{ Name: "service-payload", Source: serviceTemplates.Read(payloadT), Data: m, }) } } - if m.StreamingPayloadDef != "" { + if method.streamingPayload != nil && method.streamingPayload.location == nil && m.StreamingPayloadDef != "" { if _, ok := seen[m.StreamingPayload]; !ok { - addTypeDefSection(payloadPath, m.StreamingPayload, &codegen.SectionTemplate{ + addTypeDefSection(m.StreamingPayload, &codegen.SectionTemplate{ Name: "service-streaming-payload", Source: serviceTemplates.Read(streamingPayloadT), Data: m, }) } } - if m.ResultDef != "" { + if m.ResultLoc == nil && m.ResultDef != "" { if _, ok := seen[m.Result]; !ok { - addTypeDefSection(resultPath, m.Result, &codegen.SectionTemplate{ + addTypeDefSection(m.Result, &codegen.SectionTemplate{ Name: "service-result", Source: serviceTemplates.Read(resultT), Data: m, @@ -63,9 +149,9 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use } } // Generate streaming result type if different from result - if m.StreamingResultDef != "" && m.StreamingResult != m.Result { + if method.streamingResult != nil && method.streamingResult.location == nil && m.StreamingResultDef != "" && m.StreamingResult != m.Result { if _, ok := seen[m.StreamingResult]; !ok { - addTypeDefSection(resultPath, m.StreamingResult, &codegen.SectionTemplate{ + addTypeDefSection(m.StreamingResult, &codegen.SectionTemplate{ Name: "service-streaming-result", Source: serviceTemplates.Read(resultT), Data: map[string]any{ @@ -78,53 +164,42 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use } } for _, ut := range svc.userTypes { - if _, ok := seen[ut.VarName]; !ok { - addTypeDefSection(pathWithDefault(ut.Loc, svcPath), ut.VarName, &codegen.SectionTemplate{ - Name: "service-user-type", - Source: serviceTemplates.Read(userTypeT), - Data: ut, - }) + if ut.Loc == nil { + if _, ok := seen[ut.VarName]; !ok { + addTypeDefSection(ut.VarName, &codegen.SectionTemplate{ + Name: "service-user-type", + Source: serviceTemplates.Read(userTypeT), + Data: ut, + }) + } } } - for _, u := range svc.unions { - addTypeDefSection(pathWithDefault(u.Loc, svcPath), "~union:"+u.Name, &codegen.SectionTemplate{ - Name: "service-union-type", - Source: serviceTemplates.Read(unionTypeT), - Data: u, - }) - } - var errorTypes []*UserTypeData seenErrs := make(map[string]struct{}) for _, et := range svc.errorTypes { - if et.Type == expr.ErrorResult { + if et.IsServiceError || et.Loc != nil { continue } if _, ok := seenErrs[et.Name]; !ok { seenErrs[et.Name] = struct{}{} if _, ok := seen[et.Name]; !ok { - addTypeDefSection(pathWithDefault(et.Loc, svcPath), et.Name, &codegen.SectionTemplate{ + addTypeDefSection(et.Name, &codegen.SectionTemplate{ Name: "error-user-type", Source: serviceTemplates.Read(userTypeT), Data: et, }) } - errorTypes = append(errorTypes, et) + typeSections = append(typeSections, serviceTypeSection{ + phase: serviceErrorImplementationPhase, + name: et.Name, + section: &codegen.SectionTemplate{ + Name: "service-error", + Source: serviceTemplates.Read(errorT), + Data: et, + }, + }) } } - - for _, et := range errorTypes { - // Don't override the section created for the error type - // declaration, make sure the key does not clash with existing - // type names, make it generated last. - key := "|" + et.Name - addTypeDefSection(pathWithDefault(et.Loc, svcPath), key, &codegen.SectionTemplate{ - Name: "service-error", - Source: serviceTemplates.Read(errorT), - FuncMap: map[string]any{"errorName": errorName}, - Data: et, - }) - } for _, er := range svc.errorInits { svcSections = append(svcSections, &codegen.SectionTemplate{ Name: "error-init-func", @@ -167,280 +242,186 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use }) } - imports := []*codegen.ImportSpec{ - codegen.SimpleImport("context"), - codegen.SimpleImport("io"), - codegen.GoaImport(""), - codegen.GoaImport("security"), - codegen.NewImport(svc.ViewsPkg, genpkg+"/"+svcName+"/views"), - } - if len(svc.unions) > 0 { - imports = append(imports, - codegen.SimpleImport("bytes"), - codegen.SimpleImport("encoding/json"), - codegen.SimpleImport("fmt"), - ) - } - header := codegen.Header(service.Name+" service", svc.PkgName, imports) + header := codegen.Header(facts.name+" service", svc.PkgName, facts.imports.service.specs) def := &codegen.SectionTemplate{ Name: "service", Source: serviceTemplates.Read(serviceT), Data: svc, FuncMap: map[string]any{ - "hasJSONRPCStreaming": hasJSONRPCStreaming, - "isJSONRPCWebSocket": hasJSONRPCWebSocket, - "streamInterfaceFor": streamInterfaceFor, - "dedupeByResult": dedupeByResult, + "streamInterfaceFor": streamInterfaceFor, }, } - // service.go - var sections []*codegen.SectionTemplate - { - names := make([]string, len(typeDefSections[svcPath])) - i := 0 - for n := range typeDefSections[svcPath] { - names[i] = n - i++ - } - sections = make([]*codegen.SectionTemplate, 0, 2+len(names)+len(svcSections)) - sections = append(sections, header, def) - sort.Strings(names) - for _, n := range names { - sections = append(sections, typeDefSections[svcPath][n]) + sort.Slice(typeSections, func(i, j int) bool { + if typeSections[i].phase != typeSections[j].phase { + return typeSections[i].phase < typeSections[j].phase } - sections = append(sections, svcSections...) - } - files := []*codegen.File{{Path: svcPath, SectionTemplates: sections}} + return typeSections[i].name < typeSections[j].name + }) + sections := make([]*codegen.SectionTemplate, 0, 2+len(typeSections)+len(svcSections)) + sections = append(sections, header, def) + for _, record := range typeSections { + sections = append(sections, record.section) + } + sections = append(sections, svcSections...) + interceptors := interceptorsFiles(plan, facts) + files := make([]*codegen.File, 1, 1+len(interceptors)) + files[0] = &codegen.File{Path: svcPath, SectionTemplates: sections} + return append(files, interceptors...) +} - // service and client interceptors - files = append(files, InterceptorsFiles(genpkg, service, services)...) +// generatedPackageFiles renders each relocated user type in its configured +// file and one sorted unions.go for every package that owns unions. +func generatedPackageFiles(analyses []*ServicesData) ([]*codegen.File, error) { + packages, err := aggregateGeneratedPackages(analyses) + if err != nil { + return nil, err + } + if len(packages) == 0 { + return nil, nil + } + packageOwners := make([]*codegen.GeneratedPackage, 0, len(packages)) + for owner := range packages { + packageOwners = append(packageOwners, owner) + } + slices.SortFunc(packageOwners, func(left, right *codegen.GeneratedPackage) int { + return strings.Compare(left.ImportPath(), right.ImportPath()) + }) - // user types - paths := make([]string, len(typeDefSections)) - i := 0 - for p := range typesByPath { - paths[i] = p - i++ - } - sort.Strings(paths) - for _, p := range paths { - if p == svcPath { - continue + var files []*codegen.File + for _, owner := range packageOwners { + packagePath := owner.ImportPath() + packageName := strings.ToLower(codegen.Goify(path.Base(packagePath), false)) + generatedPackage := packages[owner] + typesByFile := make(map[string][]*generatedTypeData) + for _, generatedType := range generatedPackage.types { + filePath := filepath.Join(owner.OutputDirectory(), filepath.Base(generatedType.location.FilePath)) + typesByFile[filePath] = append(typesByFile[filePath], generatedType) } - var secs []*codegen.SectionTemplate - hasUnion := false - ts := typesByPath[p] - sort.Strings(ts) - for _, name := range ts { - if strings.HasPrefix(name, "~union:") { - hasUnion = true + filePaths := make([]string, 0, len(typesByFile)) + for filePath := range typesByFile { + filePaths = append(filePaths, filePath) + } + sort.Strings(filePaths) + for _, filePath := range filePaths { + generatedTypes := typesByFile[filePath] + sort.Slice(generatedTypes, func(i, j int) bool { + return generatedTypes[i].declaration.Name() < generatedTypes[j].declaration.Name() + }) + var imports []*codegen.ImportSpec + for _, generatedType := range generatedTypes { + imports = appendImportSpecs(imports, generatedType.imports) } - hasName := false - for _, n := range userTypePkgs[p] { - if hasName = n == name; hasName { - break - } + sections := []*codegen.SectionTemplate{ + codegen.Header("User types", packageName, imports), } - if hasName { - continue + for _, generatedType := range generatedTypes { + sections = append(sections, generatedType.section) + if generatedType.error != nil { + sections = append(sections, generatedType.error) + } } - userTypePkgs[p] = append(userTypePkgs[p], name) - secs = append(secs, typeDefSections[p][name]) - } - if len(secs) == 0 { - continue - } - fullRelPath := filepath.Join(codegen.Gendir, p) - dir, _ := filepath.Split(fullRelPath) - imports := []*codegen.ImportSpec{ - codegen.SimpleImport("fmt"), - codegen.GoaImport(""), - } - if hasUnion { - imports = append(imports, - codegen.SimpleImport("bytes"), - codegen.SimpleImport("encoding/json"), - ) + files = append(files, &codegen.File{Path: filePath, SectionTemplates: sections}) } - h := codegen.Header("User types", codegen.Goify(filepath.Base(dir), false), imports) - sections := append([]*codegen.SectionTemplate{h}, secs...) - files = append(files, &codegen.File{Path: fullRelPath, SectionTemplates: sections}) - } - - return files -} -// dedupeByResult returns a slice of methods where only a single representative -// per unique ResultRef is kept (first occurrence wins). Methods without a -// ResultRef are ignored. -func dedupeByResult(ms []*MethodData) []*MethodData { - seen := make(map[string]struct{}) - out := make([]*MethodData, 0, len(ms)) - for _, m := range ms { - key := m.Result - if key == "" { - key = m.StreamingResult - } - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, m) - } - return out -} - -// SetUserTypeImports sets the import paths for user types declared in custom -// packages with the Meta key "struct:pkg:path". -func SetUserTypeImports(genpkg string, d *Data) { - d.UserTypeImports = userTypeImports(genpkg, d) -} - -// AddServiceDataMetaTypeImports adds all imports defined by struct:field:type -// metadata for the service data. -func AddServiceDataMetaTypeImports(header *codegen.SectionTemplate, d *Data) { - codegen.AddImport(header, d.metaTypeImports...) -} - -// AddUserTypeImports adds the imports for user types declared in custom -// packages with the Meta key "struct:pkg:path". -func AddUserTypeImports(header *codegen.SectionTemplate, d *Data) { - codegen.AddImport(header, d.UserTypeImports...) -} - -func metaTypeImports(svcExpr *expr.ServiceExpr, svcData *Data) []*codegen.ImportSpec { - seen := make(map[codegen.ImportSpec]struct{}) - var imports []*codegen.ImportSpec - for _, m := range svcExpr.Methods { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(m.Payload)...) - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(m.StreamingPayload)...) - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(m.Result)...) - } - for _, ut := range svcData.userTypes { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(ut.Type.Attribute())...) - } - for _, et := range svcData.errorTypes { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(et.Type.Attribute())...) - } - for _, t := range svcData.viewedResultTypes { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(t.Type.Attribute())...) - } - for _, t := range svcData.projectedTypes { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(t.Type.Attribute())...) - } - return imports -} - -func userTypeImports(genpkg string, d *Data) []*codegen.ImportSpec { - importsByPath := make(map[string]*codegen.ImportSpec) - - initLoc := func(loc *codegen.Location) { - if loc == nil { - return - } - importsByPath[loc.FilePath] = &codegen.ImportSpec{Name: loc.PackageName(), Path: genpkg + "/" + loc.RelImportPath} - } - - // Process method-specific locations - for _, m := range d.Methods { - initLoc(m.PayloadLoc) - initLoc(m.ResultLoc) - for _, l := range m.ErrorLocs { - initLoc(l) + if len(generatedPackage.unions) > 0 { + unions := make([]*UnionTypeData, 0, len(generatedPackage.unions)) + for _, union := range generatedPackage.unions { + unions = append(unions, union) + } + sort.Slice(unions, func(i, j int) bool { + return unions[i].Name < unions[j].Name + }) + sections := []*codegen.SectionTemplate{ + codegen.Header("Union types", packageName, generatedPackage.unionImports), + } + for _, union := range unions { + sections = append(sections, &codegen.SectionTemplate{ + Name: "service-union-type", + Source: serviceTemplates.Read(unionTypeT), + Data: union, + }) + } + files = append(files, &codegen.File{ + Path: filepath.Join(owner.OutputDirectory(), "unions.go"), + SectionTemplates: sections, + }) } } - - // Process service-level types once (not per method) - for _, ut := range d.userTypes { - initLoc(ut.Loc) - } - for _, et := range d.errorTypes { - initLoc(et.Loc) - } - - imports := make([]*codegen.ImportSpec, 0, len(importsByPath)) - for _, imp := range importsByPath { // Order does not matter, imports are sorted during formatting. - imports = append(imports, imp) - } - return imports + return files, nil } -func appendUniqueImport(imports []*codegen.ImportSpec, seen map[codegen.ImportSpec]struct{}, specs ...*codegen.ImportSpec) []*codegen.ImportSpec { - for _, spec := range specs { - if _, ok := seen[*spec]; ok { - continue +// aggregateGeneratedPackages selects one render section for each generated +// package declaration across all analyzed roots without changing generation +// state. +func aggregateGeneratedPackages(analyses []*ServicesData) (map[*codegen.GeneratedPackage]*generatedPackageData, error) { + packages := make(map[*codegen.GeneratedPackage]*generatedPackageData) + for _, services := range analyses { + for owner, analyzedPackage := range services.packages { + generatedPackage, ok := packages[owner] + if !ok { + generatedPackage = &generatedPackageData{ + types: make(map[*codegen.TypeDeclaration]*generatedTypeData), + unions: make(map[*codegen.UnionDeclaration]*UnionTypeData), + } + packages[owner] = generatedPackage + } + for declaration, generatedType := range analyzedPackage.types { + if _, exists := generatedPackage.types[declaration]; exists { + return nil, fmt.Errorf( + "generated type declaration %q was assigned to more than one service plan", + declaration.Name(), + ) + } + generatedPackage.types[declaration] = generatedType + } + for declaration, union := range analyzedPackage.unions { + if _, exists := generatedPackage.unions[declaration]; exists { + return nil, fmt.Errorf( + "generated union declaration %q was assigned to more than one service plan", + union.Name, + ) + } + generatedPackage.unions[declaration] = union + } + generatedPackage.unionImports = appendImportSpecs(generatedPackage.unionImports, analyzedPackage.unionImports) } - seen[*spec] = struct{}{} - imports = append(imports, spec) } - return imports + return packages, nil } -func errorName(et *UserTypeData) string { - obj := expr.AsObject(et.Type) - if obj != nil { - for _, att := range *obj { - if _, ok := att.Attribute.Meta["struct:error:name"]; ok { - return fmt.Sprintf("e.%s", codegen.GoifyAtt(att.Attribute, att.Name, true)) - } - } +// appendImportSpecs merges exact file contributions by complete package path +// and returns them in deterministic path order. +func appendImportSpecs(existing, added []*codegen.ImportSpec) []*codegen.ImportSpec { + byPath := make(map[string]*codegen.ImportSpec, len(existing)+len(added)) + for _, spec := range existing { + byPath[spec.Path] = spec } - // if error type is a custom user type and used by at most one error, then - // error Finalize should have added "struct:error:name" to the user type - // attribute's meta. - if v, ok := et.Type.Attribute().Meta["struct:error:name"]; ok { - return fmt.Sprintf("%q", v[0]) + for _, spec := range added { + byPath[spec.Path] = spec } - return fmt.Sprintf("%q", et.Name) -} - -// hasJSONRPCStreaming returns true if the service has a JSON-RPC streaming -// endpoint (WebSocket or SSE). -func hasJSONRPCStreaming(sd *Data) bool { - for _, m := range sd.Methods { - if m.IsJSONRPC && m.ServerStream != nil { - return true - } + paths := make([]string, 0, len(byPath)) + for importPath := range byPath { + paths = append(paths, importPath) } - return false -} - -// hasJSONRPCWebSocket returns true if the service has a JSON-RPC streaming -// endpoint that uses the WebSocket transport. -func hasJSONRPCWebSocket(sd *Data) bool { - for _, m := range sd.Methods { - if m.IsJSONRPCWebSocket { - return true - } + sort.Strings(paths) + result := make([]*codegen.ImportSpec, len(paths)) + for index, importPath := range paths { + result[index] = byPath[importPath] } - return false + return result } // streamInterfaceFor builds the data to generate the client and server stream // interfaces for the given endpoint. func streamInterfaceFor(typ string, m *MethodData, stream *StreamData) map[string]any { return map[string]any{ - "Type": typ, - "Endpoint": m.Name, - "Stream": stream, - "MethodVarName": m.VarName, - "IsJSONRPC": m.IsJSONRPC, - "IsJSONRPCSSE": m.IsJSONRPCSSE && typ == "server", - "IsJSONRPCWebSocket": m.IsJSONRPCWebSocket, + "Type": typ, + "Endpoint": m.Name, + "Stream": stream, // If a view is explicitly set (ViewName is not empty) in the Result // expression, we can use that view to render the result type instead // of iterating through the list of views defined in the result type. "IsViewedResult": m.ViewedResult != nil && m.ViewedResult.ViewName == "", } } - -func pathWithDefault(loc *codegen.Location, def string) string { - if loc == nil { - return def - } - return loc.FilePath -} diff --git a/codegen/service/service_data.go b/codegen/service/service_data.go index dc18b3eea9..4141c02ceb 100644 --- a/codegen/service/service_data.go +++ b/codegen/service/service_data.go @@ -1,11 +1,10 @@ +// This file builds the values passed to service templates. Package-level Go +// names are shared by every file in the package, while each file may choose +// additional private helper names. package service import ( - "bytes" "fmt" - "slices" - "sort" - "strings" "text/template" "goa.design/goa/v3/codegen" @@ -35,11 +34,50 @@ type ( ServicesData struct { Root *expr.RootExpr Services map[string]*Data + + generation *codegen.Generation + examples *expr.ExampleGenerator + aliases *importAliases + packages map[*codegen.GeneratedPackage]*generatedPackageData + facts *rootFacts } // Data contains the data used to render the code related to a single // service. Data struct { + // ServiceDeclaration is the exact package-level service interface record. + ServiceDeclaration *codegen.NameDeclaration + // AutherDeclaration is the exact package-level authorization interface + // record. It is nil when the service has no security schemes. + AutherDeclaration *codegen.NameDeclaration + // APINameDeclaration is the exact package-level API name constant record. + APINameDeclaration *codegen.NameDeclaration + // APIVersionDeclaration is the exact package-level API version constant record. + APIVersionDeclaration *codegen.NameDeclaration + // ServiceNameDeclaration is the exact package-level service name constant record. + ServiceNameDeclaration *codegen.NameDeclaration + // MethodNamesDeclaration is the exact package-level method names variable record. + MethodNamesDeclaration *codegen.NameDeclaration + // EndpointsDeclaration is the exact package-level endpoint collection record. + EndpointsDeclaration *codegen.NameDeclaration + // NewEndpointsDeclaration is the exact endpoint constructor record. + NewEndpointsDeclaration *codegen.NameDeclaration + // ClientDeclaration is the exact package-level client record. + ClientDeclaration *codegen.NameDeclaration + // NewClientDeclaration is the exact client constructor record. + NewClientDeclaration *codegen.NameDeclaration + // ServerInterceptorsDeclaration is the server interceptor interface record. + ServerInterceptorsDeclaration *codegen.NameDeclaration + // ClientInterceptorsDeclaration is the client interceptor interface record. + ClientInterceptorsDeclaration *codegen.NameDeclaration + // ExampleStructDeclaration is the starter implementation struct record. + ExampleStructDeclaration *codegen.NameDeclaration + // ExampleConstructorDeclaration is the starter constructor record. + ExampleConstructorDeclaration *codegen.NameDeclaration + // ExampleServerInterceptorsConstructorDeclaration creates the starter + // server interceptor implementation. It is nil when the service has no + // server interceptors. + ExampleServerInterceptorsConstructorDeclaration *codegen.NameDeclaration // Name is the service name. Name string // Description is the service description. @@ -50,15 +88,16 @@ type ( APIVersion string // StructName is the service struct name. StructName string - // VarName is the service variable name (first letter in lowercase). + // VarName is the local Go variable that holds the service implementation in + // generated starter programs. VarName string // PathName is the service name as used in file and import paths. PathName string // PkgName is the name of the package containing the generated service // code. PkgName string - // ViewsPkg is the name of the package containing the projected and viewed - // result types. + // ViewsPkg is the final views package name kept for existing plugins. It + // is empty when the service does not generate a views package. ViewsPkg string // Methods lists the service interface methods. Methods []*MethodData @@ -77,10 +116,6 @@ type ( // ProtoImports lists the import specifications for the custom // proto types used by the service. ProtoImports []*codegen.ImportSpec - // UserTypeImports lists the import specifications for the user types - // used by the service. - UserTypeImports []*codegen.ImportSpec - // userTypes lists the type definitions that the service depends on. userTypes []*UserTypeData // errorTypes lists the error type definitions that the service depends on. @@ -91,17 +126,35 @@ type ( // projectedTypes lists the types which uses pointers for all fields to // define view specific validation logic. projectedTypes []*ProjectedTypeData - // unions lists the sum-type unions defined for the service. + // unions lists the values that hold one selected branch for the service. unions []*UnionTypeData + // viewUnions lists the values that hold one selected branch in the views package. + viewUnions []*UnionTypeData // viewedResultTypes lists all the viewed method result types. viewedResultTypes []*ViewedResultTypeData - // metaTypeImports lists the imports derived from struct:field:type - // metadata for the service. - metaTypeImports []*codegen.ImportSpec + // viewDerived binds the independently rebuilt view graph to declarations + // reserved while the service was planned. + viewDerived map[expr.UserType]codegen.DerivedTypeID } // MethodData describes a single service method. MethodData struct { + // EndpointDeclaration is the exact package-level endpoint constructor. + EndpointDeclaration *codegen.NameDeclaration + // EndpointInputDeclaration is the exact streaming endpoint input record. + EndpointInputDeclaration *codegen.NameDeclaration + // ServerStreamDeclaration is the exact server stream interface record. + ServerStreamDeclaration *codegen.NameDeclaration + // ClientStreamDeclaration is the exact client stream interface record. + ClientStreamDeclaration *codegen.NameDeclaration + // RequestDeclaration is the exact JSON-RPC request data record. + RequestDeclaration *codegen.NameDeclaration + // ResponseDeclaration is the exact JSON-RPC response data record. + ResponseDeclaration *codegen.NameDeclaration + // ServerEndpointWrapperDeclaration is the exact server endpoint wrapper. + ServerEndpointWrapperDeclaration *codegen.NameDeclaration + // ClientEndpointWrapperDeclaration is the exact client endpoint wrapper. + ClientEndpointWrapperDeclaration *codegen.NameDeclaration // Name is the method name. Name string // Description is the method description. @@ -120,6 +173,9 @@ type ( PayloadDef string // PayloadRef is a reference to the payload type if any, PayloadRef string + // PayloadDeclaration supplies the generated Go type name for a named payload. + // It is nil for primitive payloads. + PayloadDeclaration *codegen.TypeDeclaration // PayloadDesc is the payload type description if any. PayloadDesc string // PayloadEx is an example of a valid payload value. @@ -132,6 +188,9 @@ type ( StreamingPayloadDef string // StreamingPayloadRef is a reference to the streaming payload type if any. StreamingPayloadRef string + // StreamingPayloadDeclaration supplies the generated Go type name for a + // named streaming payload. It is nil for primitive payloads. + StreamingPayloadDeclaration *codegen.TypeDeclaration // StreamingPayloadDesc is the streaming payload type description if any. StreamingPayloadDesc string // StreamingPayloadEx is an example of a valid streaming payload value. @@ -142,6 +201,9 @@ type ( StreamingResultDef string // StreamingResultRef is the reference to the streaming result type if any. StreamingResultRef string + // StreamingResultDeclaration supplies the generated Go type name for a named + // streaming result. It is nil for primitive results. + StreamingResultDeclaration *codegen.TypeDeclaration // StreamingResultDesc is the streaming result type description if any. StreamingResultDesc string // StreamingResultEx is an example of a valid streaming result value. @@ -155,6 +217,9 @@ type ( ResultDef string // ResultRef is the reference to the result type if any. ResultRef string + // ResultDeclaration supplies the generated Go type name for a named result. + // It is nil for primitive results. + ResultDeclaration *codegen.TypeDeclaration // ResultDesc is the result type description if any. ResultDesc string // ResultEx is an example of a valid result value. @@ -164,12 +229,6 @@ type ( // ErrorLocs lists the file and Go package of the error type // if overridden via Meta indexed by error name. ErrorLocs map[string]*codegen.Location - // IsJSONRPC indicates if the endpoint is a JSON-RPC endpoint. - IsJSONRPC bool - // IsJSONRPCSSE indicates if the JSON-RPC endpoint uses SSE transport. - IsJSONRPCSSE bool - // IsJSONRPCWebSocket indicates if the JSON-RPC endpoint uses WebSocket transport. - IsJSONRPCWebSocket bool // Requirements contains the security requirements for the // method. Requirements RequirementsData @@ -194,9 +253,9 @@ type ( // StreamKind is the kind of the stream (payload or result or // bidirectional). StreamKind expr.StreamKind - // HasMixedResults indicates whether the method defines both Result and - // StreamingResult with different types, enabling content negotiation at - // the transport layer (e.g. JSON vs SSE over HTTP). + // HasMixedResults indicates whether the method defines Result and + // StreamingResult separately so HTTP can return one normal response or an + // SSE stream. HasMixedResults bool // SkipRequestBodyEncodeDecode is true if the method payload includes // the raw HTTP request body reader. @@ -231,8 +290,8 @@ type ( StreamData struct { // Interface is the name of the stream interface. Interface string - // VarName is the name of the struct type that implements the stream - // interface. + // VarName is the unexported Go type name used by transport packages for this + // stream implementation. VarName string // SendName is the name of the send function. SendName string @@ -246,14 +305,6 @@ type ( SendTypeName string // SendTypeRef is the reference to the type sent through the stream. SendTypeRef string - // SendAndCloseName is the name of the send and close function (SSE only). - SendAndCloseName string - // SendAndCloseDesc is the description for the send and close function. - SendAndCloseDesc string - // SendAndCloseWithContextName is the name of the send and close function with context. - SendAndCloseWithContextName string - // SendAndCloseWithContextDesc is the description for the send and close function with context. - SendAndCloseWithContextDesc string // RecvName is the name of the receive function. RecvName string // RecvDesc is the description for the recv function. @@ -276,10 +327,17 @@ type ( Kind expr.StreamKind } - // ErrorInitData describes an error returned by a service method of type - // ErrorResult. + // ErrorInitData describes an error returned by a service method. ErrorInitData struct { - // Name is the name of the init function. + // Declaration is the package-level constructor submitted while the service + // was planned. It is nil for custom errors because the service package does + // not generate constructors for them. + Declaration *codegen.NameDeclaration + // Name is a read-only copy of the final constructor name kept for existing + // plugins. It is empty for custom errors because they have no generated + // service constructor. + // + // Deprecated: Use Declaration.Name(). Name string // Description is the error description. Description string @@ -300,12 +358,24 @@ type ( // InterceptorData contains the data required to render the service-level // interceptor code. interceptors.go.tpl InterceptorData struct { + // InfoDeclaration is the exact interceptor metadata record. + InfoDeclaration *codegen.NameDeclaration + // PayloadDeclaration is the exact payload accessor interface when emitted. + PayloadDeclaration *codegen.NameDeclaration + // ResultDeclaration is the exact result accessor interface when emitted. + ResultDeclaration *codegen.NameDeclaration + // StreamingPayloadDeclaration is the exact streaming payload accessor interface when emitted. + StreamingPayloadDeclaration *codegen.NameDeclaration + // StreamingResultDeclaration is the exact streaming result accessor interface when emitted. + StreamingResultDeclaration *codegen.NameDeclaration // Name is the name of the interceptor used in the generated code. Name string // DesignName is the name of the interceptor as defined in the design. DesignName string // Description is the description of the interceptor from the design. Description string + // Service is the service name returned to this interceptor. + Service string // Methods Methods []*MethodInterceptorData // ReadPayload contains payload attributes that the interceptor can @@ -344,6 +414,33 @@ type ( // MethodInterceptorData contains the data required to render the // method-level interceptor code. MethodInterceptorData struct { + // InfoDeclaration is the private type that returns this method's name and + // provides its field access methods. + InfoDeclaration *codegen.NameDeclaration + // ServerUnaryInfoDeclaration is the private call information type used by + // the server endpoint call. + ServerUnaryInfoDeclaration *codegen.NameDeclaration + // ClientUnaryInfoDeclaration is the private call information type used by + // the client endpoint call. + ClientUnaryInfoDeclaration *codegen.NameDeclaration + // StreamingSendInfoDeclaration is the private call information type used + // while a stream value is sent. + StreamingSendInfoDeclaration *codegen.NameDeclaration + // StreamingRecvInfoDeclaration is the private call information type used + // while a stream value is received. + StreamingRecvInfoDeclaration *codegen.NameDeclaration + // PayloadAccessDeclaration is the exact private payload accessor struct. + PayloadAccessDeclaration *codegen.NameDeclaration + // ResultAccessDeclaration is the exact private result accessor struct. + ResultAccessDeclaration *codegen.NameDeclaration + // StreamingPayloadAccessDeclaration is the exact private streaming payload accessor struct. + StreamingPayloadAccessDeclaration *codegen.NameDeclaration + // StreamingResultAccessDeclaration is the exact private streaming result accessor struct. + StreamingResultAccessDeclaration *codegen.NameDeclaration + // ServerWrapperDeclaration is the exact server interceptor wrapper function. + ServerWrapperDeclaration *codegen.NameDeclaration + // ClientWrapperDeclaration is the exact client interceptor wrapper function. + ClientWrapperDeclaration *codegen.NameDeclaration // MethodName is the name of the method. MethodName string // PayloadAccess is the name of the payload access struct. @@ -370,6 +467,10 @@ type ( // StreamInterceptorData is the stream data for an interceptor. StreamInterceptorData struct { + // InterfaceDeclaration is the exact stream interface wrapped by this record. + InterfaceDeclaration *codegen.NameDeclaration + // WrapperDeclaration is the exact private interceptor stream wrapper struct. + WrapperDeclaration *codegen.NameDeclaration // Interface is the name of the stream interface. Interface string // SendName is the name of the send function. @@ -419,12 +520,18 @@ type ( // UserTypeData contains the data describing a user-defined type. UserTypeData struct { + // Declaration supplies this type's generated Go name and output package. + Declaration *codegen.TypeDeclaration // Name is the type name. Name string // VarName is the corresponding Go type name. VarName string // Description is the type human description. Description string + // ErrorName is the Go expression returned by GoaErrorName during planning. + ErrorName string + // IsServiceError reports whether this is Goa's built-in service error. + IsServiceError bool // Def is the type definition Go code. Def string // Ref is the reference to the type. @@ -436,18 +543,26 @@ type ( Type expr.UserType } - // UnionTypeData describes a generated sum-type union for a service. + // UnionTypeData describes a generated value that holds exactly one branch. UnionTypeData struct { - // Name is the Go type name of the union struct. + // TypeDeclaration supplies the generated union type name. + TypeDeclaration *codegen.NameDeclaration + // KindDeclaration supplies the generated type that records the selected branch. + KindDeclaration *codegen.NameDeclaration + // Name is the final union type name copied for existing plugins. + // + // Deprecated: Use TypeDeclaration. Name string - // KindName is the Go type name of the discriminator kind. + // KindName is the final selected-branch type name copied for existing plugins. + // + // Deprecated: Use KindDeclaration. KindName string // Fields describes each union branch. Fields []*UnionFieldData // Loc defines the file and Go package of the union type if overridden via // Meta. When nil the type is generated in the default service file. Loc *codegen.Location - // TypeKey is the discriminator field name for JSON marshaling (defaults to "type"). + // TypeKey is the field that records the selected branch in JSON (defaults to "type"). TypeKey string // ValueKey is the value field name for JSON marshaling (defaults to "value"). ValueKey string @@ -457,14 +572,24 @@ type ( UnionFieldData struct { // Name is the branch name as defined in the DSL. Name string - // KindConst is the Go identifier for the kind constant of this branch. + // KindConst is the final branch constant name copied for existing plugins. + // + // Deprecated: Use KindDeclaration. KindConst string + // Constructor is the final branch constructor name copied for existing plugins. + // + // Deprecated: Use ConstructorDeclaration. + Constructor string + // KindDeclaration supplies the generated constant name for this branch. + KindDeclaration *codegen.NameDeclaration + // ConstructorDeclaration supplies the generated constructor name for this branch. + ConstructorDeclaration *codegen.NameDeclaration // FieldName is the struct field name in the union. FieldName string // FieldType is the Go type used in the union struct field and public API. FieldType string - // Nilable is true when the Go branch value can be nil even though the - // canonical union value is required. + // Nilable is true when the Go branch value can be nil even though selecting + // a non-nil Goa OneOf branch value is required. Nilable bool // EmitPrimitiveAlias is true when the branch uses a generated primitive alias // that must be declared in the same file as the union type. @@ -472,7 +597,7 @@ type ( // PrimitiveAliasType is the underlying Go type used by the generated branch // alias (for example "string" or "float64"). PrimitiveAliasType string - // TypeTag is the JSON "type" discriminator value for this branch. + // TypeTag is the JSON "type" value that selects this branch. TypeTag string } @@ -570,6 +695,13 @@ type ( Attributes []string // TypeVarName is the Go variable name of the type that defines the view. TypeVarName string + // MapDeclaration is the exact package-level view map record for this type. + MapDeclaration *codegen.NameDeclaration + // ToProjected is the private constructor that copies only this view's + // fields from a service result. + ToProjected *codegen.NameDeclaration + // ToResult is the exact private constructor that removes this view. + ToResult *codegen.NameDeclaration } // ProjectedTypeData contains the data used to generate a projected type for @@ -593,7 +725,7 @@ type ( // corresponding service type. If the projected type corresponds to a // result type, then a function for each view is generated. TypeInits []*InitData - // ViewsPkg is the views package name. + // ViewsPkg is the final views package name kept for existing plugins. ViewsPkg string // Views lists the views defined on the projected type. Views []*ViewData @@ -602,7 +734,13 @@ type ( // InitData contains the data to render a constructor to initialize service // types from viewed result types and vice versa. InitData struct { - // Name is the name of the constructor function. + // Declaration is the package-level constructor submitted while the service + // was planned. + Declaration *codegen.NameDeclaration + // Name is a read-only copy of the final constructor name kept for existing + // plugins. + // + // Deprecated: Use Declaration.Name(). Name string // Description is the function description. Description string @@ -627,7 +765,13 @@ type ( // ValidateData contains data to render a validate function to validate a // projected type or a viewed result type based on views. ValidateData struct { - // Name is the validation function name. + // Declaration is the package-level validation function submitted while the + // service was planned. + Declaration *codegen.NameDeclaration + // Name is a read-only copy of the final validation function name kept for + // existing plugins. + // + // Deprecated: Use Declaration.Name(). Name string // Ref is the reference to the type on which the validation function // is defined. @@ -636,29 +780,179 @@ type ( Description string // Validate is the validation code. Validate string + // Calls lists nested validator functions called by Validate. + Calls []*ValidationCallData + } + + // ValidationCallData records the exact generated function called to validate + // one nested result value. + ValidationCallData struct { + // Declaration is the exact package-level validator function record. + Declaration *codegen.NameDeclaration + // View is the selected result-type view. + View string + // Default reports whether View is the default result-type view. + Default bool + } + + // validationFieldData describes a nested result field checked by the + // validation function for its parent's selected view. + validationFieldData struct { + Name string + Call *ValidationCallData + IsRequired bool + } + + // constructorFieldData associates one child result field with the private + // constructor called by its parent conversion. + constructorFieldData struct { + VarName string + Declaration *codegen.NameDeclaration + } + + // unionDataKey selects one Goa OneOf definition by its generated definition + // key and Go package path. + unionDataKey struct { + packagePath string + identity codegen.UnionTypeID + } + + // userTypeDataKey distinguishes in-memory design types and the generated Go + // declaration selected for each one. + userTypeDataKey struct { + origin expr.UserType + declaration *codegen.TypeDeclaration + } + + // projectedTypePair records one result type rebuilt with only a view's fields + // and the exact source declaration used to find its DerivedTypeID. + projectedTypePair struct { + source expr.UserType + projected expr.UserType + sourceAttribute *expr.AttributeExpr + projectedAttribute *expr.AttributeExpr } ) -// NewServicesData creates a new ServicesData instance for the given root. -func NewServicesData(root *expr.RootExpr) *ServicesData { - return &ServicesData{ - Services: make(map[string]*Data), - Root: root, +// linkServicesData builds service template data from the values copied during +// planning and the Go names chosen by Generation.Freeze. +func linkServicesData(facts *rootFacts, generation *codegen.Generation, aliases *importAliases) (*ServicesData, error) { + root := facts.root + data := &ServicesData{ + Root: root, + Services: make(map[string]*Data), + generation: generation, + examples: facts.examples, + aliases: aliases, + packages: make(map[*codegen.GeneratedPackage]*generatedPackageData), + facts: facts, + } + for _, service := range facts.services { + analyzed, err := data.analyze(service) + if err != nil { + return nil, err + } + service.data = analyzed + data.Services[service.name] = analyzed + } + data.registerPackageData() + return data, nil +} + +// Example computes an example for attribute. The supplied ExampleIdentity +// selects the repeatable sequence from which the values are drawn. +func (d *ServicesData) Example(attribute *expr.AttributeExpr, owner expr.ExampleIdentity) any { + return attribute.Example(d.examples.At(owner)) +} + +// FieldExample computes attribute's example from the same repeatable sequence +// as the matching field in parent. Fields of a named user type use that type's +// ExampleIdentity; fields of an anonymous parent use the caller's value. +func (d *ServicesData) FieldExample(attribute, parent *expr.AttributeExpr, name string, owner expr.ExampleIdentity) any { + if typ, ok := parent.Type.(expr.UserType); ok { + owner = expr.UserTypeExampleIdentity(typ) } + return attribute.Example(d.examples.At(owner).Member(name)) } -// Get retrieves the data for the service with the given name computing it if -// needed. It returns nil if there is no service with the given name. +// Get retrieves the analyzed data for the service with the given name. It +// returns nil if there is no service with the given name. func (d *ServicesData) Get(name string) *Data { - if data, ok := d.Services[name]; ok { - return data + return d.Services[name] +} + +// GenPkg returns the generated module import path shared by every declaration, +// import alias, and transport built from this service analysis. +func (d *ServicesData) GenPkg() string { + return d.generation.GenPkg() +} + +// ServiceImport returns the import path and Go name used by outputPackage for +// the generated package of service name. The returned value is a copy that +// callers may add to one file. +func (d *ServicesData) ServiceImport(outputPackage, name string) *codegen.ImportSpec { + serviceFacts := d.facts.serviceByID[name] + if serviceFacts == nil { + panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) } - service := d.Root.Service(name) - if service == nil { - return nil + spec := d.aliases.spec(outputPackage, serviceFacts.packagePath) + return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} +} + +// ViewImport returns the import path and Go name used by outputPackage for the +// views package of service name. The returned value is a copy that callers may +// add to one file. +func (d *ServicesData) ViewImport(outputPackage, name string) *codegen.ImportSpec { + serviceFacts := d.facts.serviceByID[name] + if serviceFacts == nil { + panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) } - d.Services[name] = d.analyze(service) - return d.Services[name] + spec := d.aliases.spec(outputPackage, serviceFacts.viewsPath) + return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} +} + +// PackageImport returns the import path and Go name used by outputPackage for +// importPath. The returned value is a copy that callers may add to one file. +func (d *ServicesData) PackageImport(outputPackage, importPath string) *codegen.ImportSpec { + spec := d.aliases.spec(outputPackage, importPath) + return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} +} + +// ServiceAttributor returns a type writer for service name as referenced from +// outputPackage. It follows explicit generated package locations and uses the +// same import names as service templates. +func (d *ServicesData) ServiceAttributor(name, outputPackage string) codegen.Attributor { + serviceFacts := d.facts.serviceByID[name] + if serviceFacts == nil { + panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) + } + return newServiceResolver( + d.generation, + d.aliases, + serviceFacts.name, + serviceFacts.packagePath, + outputPackage, + ). + withValidators(serviceFacts.validators) +} + +// ViewAttributor returns a type writer for service name's result views as +// referenced from outputPackage. +func (d *ServicesData) ViewAttributor(name, outputPackage string) codegen.Attributor { + serviceFacts := d.facts.serviceByID[name] + if serviceFacts == nil { + panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) + } + data := d.Services[name] + return newViewResolver( + d.generation, + d.aliases, + serviceFacts.name, + serviceFacts.viewsPath, + data.viewDerived, + ). + withValidators(serviceFacts.validators). + withOutputPackage(outputPackage) } // Method returns the service method data for the method with the given name, @@ -737,1598 +1031,3 @@ func (s SchemesData) DedupeByType() SchemesData { return uniqueSchemes } - -// analyze creates the data necessary to render the code of the given service. -// It records the user types needed by the service definition in userTypes. -func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { - var ( - types []*UserTypeData - errTypes []*UserTypeData - errorInits []*ErrorInitData - projTypes []*ProjectedTypeData - viewedRTs []*ViewedResultTypeData - ) - scope := codegen.NewNameScope() - scope.Unique("Use") // Reserve "Use" for Endpoints struct Use method. - scope.Unique("websocket") // Reserve "websocket" to avoid collision with gorilla/websocket - viewScope := codegen.NewNameScope() - pkgName := scope.HashedUnique(service, strings.ToLower(codegen.Goify(service.Name, false)), "svc") - viewspkg := pkgName + "views" - seen := make(map[string]struct{}) - seenErrors := make(map[string]struct{}) - seenProj := make(map[string]*ProjectedTypeData) - seenViewed := make(map[string]*ViewedResultTypeData) - - // A function to collect user types from an error expression - recordError := func(er *expr.ErrorExpr) { - errTypes = append(errTypes, collectTypes(er.AttributeExpr, scope, seen, nil)...) - if er.Type == expr.ErrorResult { - if _, ok := seenErrors[er.Name]; ok { - return - } - seenErrors[er.Name] = struct{}{} - errorInits = append(errorInits, buildErrorInitData(er, scope)) - } - } - for _, er := range service.Errors { - recordError(er) - } - - // A function to collect inner user types from an attribute expression - collectUserTypes := func(att *expr.AttributeExpr) { - if att == nil { - return - } - var loc *codegen.Location - if ut, ok := att.Type.(expr.UserType); ok { - loc = codegen.UserTypeLocation(ut) - att = ut.Attribute() - } - types = append(types, collectTypes(att, scope, seen, loc)...) - } - for _, m := range service.Methods { - // collect inner user types - collectUserTypes(m.Payload) - collectUserTypes(m.StreamingPayload) - collectUserTypes(m.Result) - // Collect streaming result types if different from Result - if m.HasMixedResults() { - collectUserTypes(m.StreamingResult) - } - // Collect projected types - if hasResultType(m.Result) { - projected, result := projectedResultRoot(service, m) - ptypes := collectProjectedTypes(projected, result, viewspkg, scope, viewScope, seenProj) - projTypes = append(projTypes, ptypes...) - } - for _, er := range m.Errors { - recordError(er) - } - } - - // A function to record method user types so that forced types are not - // collected twice. Raw object method types are wrapped into synthesized - // user types by codegen.NormalizeRoot before any generator runs: analyze - // reads the design and never mutates it, so a raw object here means the - // root was not normalized. - recordMethodType := func(m *expr.MethodExpr, att *expr.AttributeExpr) { - if att == nil { - return - } - if _, ok := att.Type.(*expr.Object); ok { - panic(fmt.Sprintf( - "service %q method %q declares a raw object type: codegen.NormalizeRoot must run after eval finalization and before the generators read the design", - service.Name, m.Name)) // bug - } - if ut, ok := att.Type.(expr.UserType); ok { - seen[ut.ID()] = struct{}{} - } - } - - for _, m := range service.Methods { - recordMethodType(m, m.Payload) - recordMethodType(m, m.StreamingPayload) - recordMethodType(m, m.Result) - if m.HasMixedResults() { - recordMethodType(m, m.StreamingResult) - } - } - - // Add forced types - for _, t := range d.Root.Types { - svcs, ok := t.Attribute().Meta["type:generate:force"] - if !ok { - continue - } - att := &expr.AttributeExpr{Type: t} - if len(svcs) > 0 { - // Force generate type only in the specified services - if slices.Contains(svcs, service.Name) { - types = append(types, collectTypes(att, scope, seen, nil)...) - } - continue - } - // Force generate type in all the services - types = append(types, collectTypes(att, scope, seen, nil)...) - } - - var ( - methods []*MethodData - schemes SchemesData - ) - methods = make([]*MethodData, len(service.Methods)) - for i, e := range service.Methods { - m := d.buildMethodData(e, scope) - methods[i] = m - for _, s := range m.Schemes { - schemes = schemes.Append(s) - } - rt, ok := e.Result.Type.(*expr.ResultTypeExpr) - if !ok { - continue - } - var view string - if v, ok := e.Result.Meta.Last(expr.ViewMetaKey); ok { - view = v - } - if vrt, ok := seenViewed[m.Result+"::"+view]; ok { - m.ViewedResult = vrt - continue - } - projected := seenProj[rt.ID()] - projAtt := &expr.AttributeExpr{Type: projected.Type} - vrt := buildViewedResultType(e.Result, projAtt, viewspkg, scope, viewScope) - found := false - for _, rt := range viewedRTs { - if rt.Type.ID() == vrt.Type.ID() { - found = true - break - } - } - if !found { - viewedRTs = append(viewedRTs, vrt) - } - m.ViewedResult = vrt - seenViewed[vrt.Name+"::"+view] = vrt - } - - // Compute unique EndpointField names using the service-level scope, after - // method names are set. This records field identifiers without changing - // existing method names. - for _, m := range methods { - m.EndpointField = scope.Unique(m.VarName+"Endpoint", "") - if m.HasMixedResults { - m.StreamEndpointField = scope.Unique(m.VarName+"StreamEndpoint", "") - } - } - - // Collect union sum-type definitions for the service. - unionByPackage := make(map[string]*UnionTypeData) - seen = make(map[string]struct{}) - collectUnions := func(att *expr.AttributeExpr, loc *codegen.Location) { - collectUnionTypes(att, scope, loc, unionByPackage, seen, false) - } - for _, t := range types { - collectUnions(&expr.AttributeExpr{Type: t.Type}, t.Loc) - } - for _, t := range errTypes { - collectUnions(&expr.AttributeExpr{Type: t.Type}, t.Loc) - } - for _, m := range service.Methods { - if m.Payload != nil { - collectUnions(m.Payload, codegen.UserTypeLocation(m.Payload.Type)) - } - if m.StreamingPayload != nil { - collectUnions(m.StreamingPayload, codegen.UserTypeLocation(m.StreamingPayload.Type)) - } - if m.Result != nil { - collectUnions(m.Result, codegen.UserTypeLocation(m.Result.Type)) - } - for _, e := range m.Errors { - collectUnions(e.AttributeExpr, codegen.UserTypeLocation(e.Type)) - } - } - unions := make([]*UnionTypeData, 0, len(unionByPackage)) - for _, u := range unionByPackage { - unions = append(unions, u) - } - sort.Slice(unions, func(i, j int) bool { - if unions[i].Name != unions[j].Name { - return unions[i].Name < unions[j].Name - } - return unionPackageKey(unions[i].Loc, false) < unionPackageKey(unions[j].Loc, false) - }) - - desc := service.Description - if desc == "" { - desc = fmt.Sprintf("Service is the %s service interface.", service.Name) - } - - varName := codegen.Goify(service.Name, false) - data := &Data{ - Name: service.Name, - Description: desc, - APIName: d.Root.API.Name, - APIVersion: d.Root.API.Version, - VarName: varName, - PathName: codegen.SnakeCase(varName), - StructName: codegen.Goify(service.Name, true), - PkgName: pkgName, - ViewsPkg: viewspkg, - Methods: methods, - Schemes: schemes, - ServerInterceptors: d.collectInterceptors(service, methods, scope, true), - ClientInterceptors: d.collectInterceptors(service, methods, scope, false), - Scope: scope, - ViewScope: viewScope, - errorTypes: errTypes, - errorInits: errorInits, - userTypes: types, - projectedTypes: projTypes, - viewedResultTypes: viewedRTs, - unions: unions, - } - data.metaTypeImports = metaTypeImports(service, data) - - d.Services[service.Name] = data - - return data -} - -// collectInterceptors returns the set of interceptors defined on the given -// service including any interceptor defined on specific service methods or API. -func (d *ServicesData) collectInterceptors(svc *expr.ServiceExpr, methods []*MethodData, scope *codegen.NameScope, server bool) []*InterceptorData { - var ints []*expr.InterceptorExpr - if server { - ints = d.Root.API.ServerInterceptors - ints = append(ints, svc.ServerInterceptors...) - for _, m := range svc.Methods { - ints = append(ints, m.ServerInterceptors...) - } - } else { - ints = d.Root.API.ClientInterceptors - ints = append(ints, svc.ClientInterceptors...) - for _, m := range svc.Methods { - ints = append(ints, m.ClientInterceptors...) - } - } - // remove duplicate interceptors - sort.Slice(ints, func(i, j int) bool { - return ints[i].Name < ints[j].Name - }) - for i := 1; i < len(ints); i++ { - if ints[i-1].Name == ints[i].Name { - ints = append(ints[:i], ints[i+1:]...) - i-- - } - } - - res := make([]*InterceptorData, 0, len(ints)) - for _, i := range ints { - res = append(res, buildInterceptorData(svc, methods, i, scope, server)) - } - return res -} - -// typeContext returns a contextual attribute for service types. Service types -// are Go types and uses non-pointers to hold attributes having default values. -func typeContext(scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(false, false, true, "", scope) -} - -// projectedTypeContext returns a contextual attribute for a projected type. -// Projected types are Go types that uses pointers for all attributes (even the -// required ones). -func projectedTypeContext(pkg string, ptr bool, scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(ptr, false, true, pkg, scope) -} - -// collectTypes recurses through the attribute to gather all user types and -// records them in userTypes. -func collectTypes(at *expr.AttributeExpr, scope *codegen.NameScope, seen map[string]struct{}, loc *codegen.Location) (data []*UserTypeData) { - if at == nil || at.Type == expr.Empty { - return data - } - collect := func(at *expr.AttributeExpr, loc *codegen.Location) []*UserTypeData { - return collectTypes(at, scope, seen, loc) - } - switch dt := at.Type.(type) { - case expr.UserType: - if _, ok := seen[dt.ID()]; ok { - return nil - } - typeLoc := codegen.UserTypeLocation(dt) - if typeLoc == nil { - typeLoc = loc - } - data = append(data, &UserTypeData{ - Name: dt.Name(), - VarName: scope.GoTypeName(at), - Description: dt.Attribute().Description, - Def: scope.GoTypeDef(dt.Attribute(), false, true), - Ref: scope.GoTypeRef(at), - Loc: typeLoc, - Type: dt, - }) - seen[dt.ID()] = struct{}{} - data = append(data, collect(dt.Attribute(), typeLoc)...) - case *expr.Object: - for _, nat := range *dt { - data = append(data, collect(nat.Attribute, loc)...) - } - case *expr.Array: - data = append(data, collect(dt.ElemType, loc)...) - case *expr.Map: - data = append(data, collect(dt.KeyType, loc)...) - data = append(data, collect(dt.ElemType, loc)...) - case *expr.Union: - for _, nat := range dt.Values { - data = append(data, collect(nat.Attribute, loc)...) - } - } - return data -} - -// collectUnionTypes traverses the attribute to gather all union sum-type -// definitions referenced by the service. It records each union by its hash and -// generated package so Extend can copy one union into multiple packages while -// duplicate uses within one package still share a definition. When view is true -// the provided location is used for all nested user types so that unions are -// generated in the views package and refer to view-local types (preventing -// import cycles). -func collectUnionTypes(att *expr.AttributeExpr, scope *codegen.NameScope, loc *codegen.Location, unions map[string]*UnionTypeData, seen map[string]struct{}, view bool) { - if att == nil || att.Type == expr.Empty { - return - } - switch dt := att.Type.(type) { - case expr.UserType: - if _, ok := seen[dt.ID()]; ok { - return - } - seen[dt.ID()] = struct{}{} - typeLoc := loc - if !view { - typeLoc = codegen.UserTypeLocation(dt) - } - collectUnionTypes(dt.Attribute(), scope, typeLoc, unions, seen, view) - case *expr.Object: - for _, nat := range sortedNamedAttributes(*dt) { - collectUnionTypes(nat.Attribute, scope, loc, unions, seen, view) - } - case *expr.Array: - collectUnionTypes(dt.ElemType, scope, loc, unions, seen, view) - case *expr.Map: - collectUnionTypes(dt.KeyType, scope, loc, unions, seen, view) - collectUnionTypes(dt.ElemType, scope, loc, unions, seen, view) - case *expr.Union: - key := dt.Hash() + "\x00" + unionPackageKey(loc, view) - if _, ok := unions[key]; !ok { - unions[key] = buildUnionTypeData(dt, scope, loc, view) - } - for _, nat := range dt.Values { - collectUnionTypes(nat.Attribute, scope, loc, unions, seen, view) - } - } -} - -// unionPackageKey identifies the generated package that owns a union. A nil -// location is the current service package; viewed unions share the views -// package regardless of locations inherited from service types. -func unionPackageKey(loc *codegen.Location, view bool) string { - if view { - return "views" - } - if loc == nil { - return "" - } - return loc.RelImportPath -} - -// buildUnionTypeData creates the data needed to generate a sum-type union -// struct, its discriminator kind, and branch metadata. When view is true the -// union is generated in the views package: field types are computed using the -// view scope and are always emitted unqualified so they refer to the -// view-local projected types. -func buildUnionTypeData(u *expr.Union, scope *codegen.NameScope, loc *codegen.Location, view bool) *UnionTypeData { - att := &expr.AttributeExpr{Type: u} - name := scope.GoTypeName(att) - kindName := scope.Unique(name + "Kind") - var unionPkg string - if !view { - unionPkg = loc.PackageName() - } - - fields := make([]*UnionFieldData, len(u.Values)) - for i, nat := range u.Values { - fieldName := codegen.Goify(nat.Name, true) - var pkg string - if !view { - if tloc := codegen.UserTypeLocation(nat.Attribute.Type); tloc != nil { - pkg = tloc.PackageName() - if pkg == unionPkg { - pkg = "" - } - } - } - fieldType := scope.GoFullTypeRef(nat.Attribute, pkg) - primitiveAliasType, hasPrimitiveAlias := primitiveAliasGoType(nat.Attribute.Type) - _, isUserType := nat.Attribute.Type.(expr.UserType) - emitPrimitiveAlias := hasPrimitiveAlias && !isUserType && pkg == "" - kindConst := kindName + codegen.Goify(nat.Name, true) - fields[i] = &UnionFieldData{ - Name: nat.Name, - KindConst: kindConst, - FieldName: fieldName, - FieldType: fieldType, - Nilable: codegen.IsNilable(nat.Attribute.Type), - EmitPrimitiveAlias: emitPrimitiveAlias, - PrimitiveAliasType: primitiveAliasType, - TypeTag: nat.Name, - } - } - - return &UnionTypeData{ - Name: name, - KindName: kindName, - Fields: fields, - Loc: loc, - TypeKey: u.GetTypeKey(), - ValueKey: u.GetValueKey(), - } -} - -// sortedNamedAttributes returns object fields sorted by attribute name. -// Union naming uses NameScope uniqueness, so callers that discover unions while -// traversing objects must use a deterministic field order to avoid oscillating -// generated identifiers across runs. -func sortedNamedAttributes(attrs []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { - if len(attrs) < 2 { - return attrs - } - sorted := slices.Clone(attrs) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Name < sorted[j].Name - }) - return sorted -} - -// primitiveAliasGoType resolves the native Go type for a primitive alias branch. -// It uses expr.IsPrimitive to enforce the type contract and then unwraps aliases. -func primitiveAliasGoType(dt expr.DataType) (string, bool) { - if !expr.IsPrimitive(dt) { - return "", false - } - for { - ut, ok := dt.(expr.UserType) - if !ok { - return codegen.GoNativeTypeName(dt), true - } - dt = ut.Attribute().Type - } -} - -// buildErrorInitData creates the data needed to generate code around endpoint error return values. -func buildErrorInitData(er *expr.ErrorExpr, scope *codegen.NameScope) *ErrorInitData { - _, temporary := er.Meta["goa:error:temporary"] - _, timeout := er.Meta["goa:error:timeout"] - _, fault := er.Meta["goa:error:fault"] - var pkg string - if ut, ok := er.Type.(expr.UserType); ok { - pkg = codegen.UserTypeLocation(ut).PackageName() - } - return &ErrorInitData{ - Name: fmt.Sprintf("Make%s", codegen.Goify(er.Name, true)), - Description: er.Description, - ErrName: er.Name, - TypeName: scope.GoTypeName(er.AttributeExpr), - TypeRef: scope.GoFullTypeRef(er.AttributeExpr, pkg), - Temporary: temporary, - Timeout: timeout, - Fault: fault, - } -} - -// buildMethodData creates the data needed to render the given endpoint. It -// records the user types needed by the service definition in userTypes. -func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameScope) *MethodData { - var ( - vname string - desc string - payloadName string - payloadLoc *codegen.Location - payloadDef string - payloadRef string - payloadDesc string - payloadEx any - rname string - resultLoc *codegen.Location - resultDef string - resultRef string - resultDesc string - resultEx any - errors []*ErrorInitData - errorLocs map[string]*codegen.Location - isJSONRPC bool - reqs = make(RequirementsData, 0, len(m.Requirements)) - schemes SchemesData - ) - vname = scope.Unique(codegen.Goify(m.Name, true), "Endpoint") - desc = m.Description - if desc == "" { - desc = codegen.Goify(m.Name, true) + " implements " + m.Name + "." - } - if m.Payload.Type != expr.Empty { - payloadName = scope.GoTypeName(m.Payload) - if dt, ok := m.Payload.Type.(expr.UserType); ok { - payloadDef = scope.GoTypeDef(dt.Attribute(), false, true) - payloadLoc = codegen.UserTypeLocation(dt) - } - payloadRef = scope.GoFullTypeRef(m.Payload, payloadLoc.PackageName()) - payloadDesc = m.Payload.Description - if payloadDesc == "" { - payloadDesc = fmt.Sprintf("%s is the payload type of the %s service %s method.", - payloadName, m.Service.Name, m.Name) - } - payloadEx = m.Payload.Example(d.Root.API.ExampleGenerator) - } - if m.Result.Type != expr.Empty { - rname = scope.GoTypeName(m.Result) - if dt, ok := m.Result.Type.(expr.UserType); ok { - resultDef = scope.GoTypeDef(dt.Attribute(), false, true) - resultLoc = codegen.UserTypeLocation(dt) - } - resultRef = scope.GoFullTypeRef(m.Result, resultLoc.PackageName()) - resultDesc = m.Result.Description - if resultDesc == "" { - resultDesc = fmt.Sprintf("%s is the result type of the %s service %s method.", - rname, m.Service.Name, m.Name) - } - resultEx = m.Result.Example(d.Root.API.ExampleGenerator) - } - if len(m.Errors) > 0 { - errors = make([]*ErrorInitData, len(m.Errors)) - errorLocs = make(map[string]*codegen.Location, len(m.Errors)) - for i, er := range m.Errors { - errors[i] = buildErrorInitData(er, scope) - errorLocs[er.Name] = codegen.UserTypeLocation(er.Type) - } - } - - _, isJSONRPC = m.Meta["jsonrpc"] - - // Check if this JSON-RPC method uses SSE or WebSocket - var isJSONRPCSSE bool - var isJSONRPCWebSocket bool - if isJSONRPC && m.IsStreaming() { - if httpJSONRPCSvc := d.Root.API.JSONRPC.HTTPExpr.Service(m.Service.Name); httpJSONRPCSvc != nil { - for _, e := range httpJSONRPCSvc.HTTPEndpoints { - if e.MethodExpr.Name == m.Name { - if e.SSE != nil { - isJSONRPCSSE = true - } else { - isJSONRPCWebSocket = true - } - break - } - } - } - } - - for _, req := range expr.EffectiveSecurityRequirements(m.Requirements) { - var rs SchemesData - for _, s := range req.Schemes { - sch := BuildSchemeData(s, m) - rs = rs.Append(sch) - schemes = schemes.Append(sch) - } - reqs = append(reqs, &RequirementData{Schemes: rs, Scopes: req.Scopes}) - } - - // Unfortunately we can't completely isolate the service codegen from - // the underlying transport when wanting to skip Goa's built-in decoding. - skipRequestBodyEncodeDecode := false - skipResponseBodyEncodeDecode := false - var httpSvc *expr.HTTPServiceExpr - for _, svc := range d.Root.API.HTTP.Services { - if svc.Name() == m.Service.Name { - httpSvc = svc - break - } - } - if httpSvc != nil { - if httpMet := httpSvc.Endpoint(m.Name); httpMet != nil { - skipRequestBodyEncodeDecode = httpMet.SkipRequestBodyEncodeDecode - skipResponseBodyEncodeDecode = httpMet.SkipResponseBodyEncodeDecode - } - } - - data := &MethodData{ - Name: m.Name, - VarName: vname, - Description: desc, - Idempotent: m.Idempotent, - Payload: payloadName, - PayloadLoc: payloadLoc, - PayloadDef: payloadDef, - PayloadRef: payloadRef, - PayloadDesc: payloadDesc, - PayloadEx: payloadEx, - PayloadDefault: m.Payload.DefaultValue, - Result: rname, - ResultLoc: resultLoc, - ResultDef: resultDef, - ResultRef: resultRef, - ResultDesc: resultDesc, - ResultEx: resultEx, - Errors: errors, - ErrorLocs: errorLocs, - IsJSONRPC: isJSONRPC, - IsJSONRPCSSE: isJSONRPCSSE, - IsJSONRPCWebSocket: isJSONRPCWebSocket, - Requirements: reqs, - Schemes: schemes, - StreamKind: m.Stream, - HasMixedResults: m.HasMixedResults(), - SkipRequestBodyEncodeDecode: skipRequestBodyEncodeDecode, - SkipResponseBodyEncodeDecode: skipResponseBodyEncodeDecode, - RequestStruct: vname + "RequestData", - ResponseStruct: vname + "ResponseData", - } - - d.initStreamData(data, m, vname, rname, resultRef, scope) - return data -} - -// initStreamData initializes the streaming payload data structures and methods. -func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vname, rname, resultRef string, scope *codegen.NameScope) { - if !m.IsStreaming() && !m.HasMixedResults() { - return - } - var ( - spayloadName string - spayloadRef string - spayloadDef string - spayloadDesc string - spayloadEx any - srname = rname // streaming result name - srref = resultRef // streaming result ref - ) - - // If StreamingResult is different from Result, use it for streaming - if m.HasMixedResults() && m.StreamingResult != nil && m.StreamingResult.Type != expr.Empty { - srname = scope.GoTypeName(m.StreamingResult) - srref = scope.GoTypeRef(m.StreamingResult) - data.StreamingResult = srname - data.StreamingResultRef = srref - if dt, ok := m.StreamingResult.Type.(expr.UserType); ok { - data.StreamingResultDef = scope.GoTypeDef(dt.Attribute(), false, true) - } - data.StreamingResultDesc = m.StreamingResult.Description - if data.StreamingResultDesc == "" { - data.StreamingResultDesc = fmt.Sprintf("%s is the streaming result type of the %s service %s method.", - srname, m.Service.Name, m.Name) - } - data.StreamingResultEx = m.StreamingResult.Example(d.Root.API.ExampleGenerator) - } - - if m.StreamingPayload != nil && m.StreamingPayload.Type != expr.Empty { - spayloadName = scope.GoTypeName(m.StreamingPayload) - spayloadRef = scope.GoTypeRef(m.StreamingPayload) - if dt, ok := m.StreamingPayload.Type.(expr.UserType); ok { - spayloadDef = scope.GoTypeDef(dt.Attribute(), false, true) - } - spayloadDesc = m.StreamingPayload.Description - if spayloadDesc == "" { - spayloadDesc = fmt.Sprintf("%s is the streaming payload type of the %s service %s method.", - spayloadName, m.Service.Name, m.Name) - } - spayloadEx = m.StreamingPayload.Example(d.Root.API.ExampleGenerator) - } - // For JSON-RPC WebSocket: - // - Client streaming (no result streaming): no endpoint struct needed, just payload - // - Bidirectional streaming: endpoint struct needed for both payload and stream - endpointStruct := vname + "EndpointInput" - if data.IsJSONRPC && m.IsStreaming() && !data.IsJSONRPCSSE && m.Stream == expr.ClientStreamKind { - endpointStruct = "" - } - // For mixed results with SSE, treat as server streaming - streamKind := m.Stream - if m.HasMixedResults() && !m.IsStreaming() { - // Mixed results with SSE should be treated as server streaming - streamKind = expr.ServerStreamKind - } - svrStream := &StreamData{ - Interface: vname + "ServerStream", - VarName: scope.Unique(codegen.Goify(m.Name, true), "ServerStream"), - EndpointStruct: endpointStruct, - Kind: streamKind, - SendName: "Send", - SendDesc: fmt.Sprintf("Send streams instances of %q.", srname), - SendWithContextName: "SendWithContext", - SendWithContextDesc: fmt.Sprintf("SendWithContext streams instances of %q with context.", srname), - SendTypeName: srname, - SendTypeRef: srref, - MustClose: true, - } - cliStream := &StreamData{ - Interface: vname + "ClientStream", - VarName: scope.Unique(codegen.Goify(m.Name, true), "ClientStream"), - Kind: streamKind, - RecvName: "Recv", - RecvDesc: fmt.Sprintf("Recv reads instances of %q from the stream.", srname), - RecvWithContextName: "RecvWithContext", - RecvWithContextDesc: fmt.Sprintf("RecvWithContext reads instances of %q from the stream with context.", srname), - RecvTypeName: srname, - RecvTypeRef: srref, - } - // For SSE server streaming, we need both Send (for notifications) and SendAndClose (for final response) - if data.IsJSONRPCSSE && m.Stream == expr.ServerStreamKind && resultRef != "" { - svrStream.SendAndCloseName = "SendAndClose" - svrStream.SendAndCloseDesc = fmt.Sprintf("SendAndClose sends a final response with %q and closes the stream.", srname) - // For JSON-RPC SSE, methods take context directly; align names accordingly - svrStream.SendWithContextName = "Send" - svrStream.RecvWithContextName = "Recv" - // Update Send description to clarify it's for notifications only - svrStream.SendDesc = fmt.Sprintf("Send streams JSON-RPC notifications with %q. Notifications do not expect a response.", srname) - } - if streamKind == expr.ClientStreamKind || streamKind == expr.BidirectionalStreamKind { - switch streamKind { - case expr.ClientStreamKind: - if srref != "" { - svrStream.SendName = "SendAndClose" - svrStream.SendDesc = fmt.Sprintf("SendAndClose streams instances of %q and closes the stream.", srname) - svrStream.SendWithContextName = "SendAndCloseWithContext" - svrStream.SendWithContextDesc = fmt.Sprintf("SendAndCloseWithContext streams instances of %q and closes the stream with context.", srname) - svrStream.MustClose = false - cliStream.RecvName = "CloseAndRecv" - cliStream.RecvDesc = fmt.Sprintf("CloseAndRecv stops sending messages to the stream and reads instances of %q from the stream.", srname) - cliStream.RecvWithContextName = "CloseAndRecvWithContext" - cliStream.RecvWithContextDesc = fmt.Sprintf("CloseAndRecvWithContext stops sending messages to the stream and reads instances of %q from the stream with context.", srname) - } else { - cliStream.MustClose = true - } - case expr.BidirectionalStreamKind: - cliStream.MustClose = true - } - svrStream.RecvName = "Recv" - svrStream.RecvDesc = fmt.Sprintf("Recv reads instances of %q from the stream.", spayloadName) - svrStream.RecvWithContextName = "RecvWithContext" - svrStream.RecvWithContextDesc = fmt.Sprintf("RecvWithContext reads instances of %q from the stream with context.", spayloadName) - svrStream.RecvTypeName = spayloadName - svrStream.RecvTypeRef = spayloadRef - cliStream.SendName = "Send" - cliStream.SendDesc = fmt.Sprintf("Send streams instances of %q.", spayloadName) - cliStream.SendWithContextName = "SendWithContext" - cliStream.SendWithContextDesc = fmt.Sprintf("SendWithContext streams instances of %q with context.", spayloadName) - cliStream.SendTypeName = spayloadName - cliStream.SendTypeRef = spayloadRef - } - data.ClientStream = cliStream - data.ServerStream = svrStream - data.StreamingPayload = spayloadName - data.StreamingPayloadDef = spayloadDef - data.StreamingPayloadRef = spayloadRef - data.StreamingPayloadDesc = spayloadDesc - data.StreamingPayloadEx = spayloadEx -} - -// buildInterceptorData creates the data needed to generate interceptor code. -func buildInterceptorData(svc *expr.ServiceExpr, methods []*MethodData, i *expr.InterceptorExpr, scope *codegen.NameScope, server bool) *InterceptorData { - data := &InterceptorData{ - Name: codegen.Goify(i.Name, true), - DesignName: i.Name, - Description: i.Description, - } - if len(svc.Methods) == 0 { - return data - } - attributesCollected := false - for _, m := range svc.Methods { - applies := false - intExprs := m.ServerInterceptors - if !server { - intExprs = m.ClientInterceptors - } - for _, in := range intExprs { - if in.Name == i.Name { - if !attributesCollected { - payload, result, streamingPayload := m.Payload, m.Result, m.StreamingPayload - data.ReadPayload = collectAttributes(i.ReadPayload, payload, scope) - data.WritePayload = collectAttributes(i.WritePayload, payload, scope) - data.ReadResult = collectAttributes(i.ReadResult, result, scope) - data.WriteResult = collectAttributes(i.WriteResult, result, scope) - data.ReadStreamingPayload = collectAttributes(i.ReadStreamingPayload, streamingPayload, scope) - data.WriteStreamingPayload = collectAttributes(i.WriteStreamingPayload, streamingPayload, scope) - data.ReadStreamingResult = collectAttributes(i.ReadStreamingResult, result, scope) - data.WriteStreamingResult = collectAttributes(i.WriteStreamingResult, result, scope) - if len(data.ReadPayload) > 0 || len(data.WritePayload) > 0 { - data.HasPayloadAccess = true - } - if len(data.ReadResult) > 0 || len(data.WriteResult) > 0 { - data.HasResultAccess = true - } - if len(data.ReadStreamingPayload) > 0 || len(data.WriteStreamingPayload) > 0 { - data.HasStreamingPayloadAccess = true - } - if len(data.ReadStreamingResult) > 0 || len(data.WriteStreamingResult) > 0 { - data.HasStreamingResultAccess = true - } - attributesCollected = true - } - applies = true - break - } - } - if !applies { - continue - } - var md *MethodData - for _, mt := range methods { - if m.Name == mt.Name { - md = mt - break - } - } - data.Methods = append(data.Methods, buildInterceptorMethodData(i, md)) - if server { - md.ServerInterceptors = append(md.ServerInterceptors, i.Name) - } else { - md.ClientInterceptors = append(md.ClientInterceptors, i.Name) - } - } - return data -} - -// buildInterceptorMethodData creates the data needed to generate interceptor -// method code. -func buildInterceptorMethodData(i *expr.InterceptorExpr, md *MethodData) *MethodInterceptorData { - var serverStream, clientStream *StreamInterceptorData - if md.ServerStream != nil { - serverStream = &StreamInterceptorData{ - Interface: md.ServerStream.Interface, - SendName: md.ServerStream.SendName, - SendWithContextName: md.ServerStream.SendWithContextName, - SendTypeRef: md.ServerStream.SendTypeRef, - RecvName: md.ServerStream.RecvName, - RecvWithContextName: md.ServerStream.RecvWithContextName, - RecvTypeRef: md.ServerStream.RecvTypeRef, - MustClose: md.ServerStream.MustClose, - EndpointStruct: md.ServerStream.EndpointStruct, - } - } - if md.ClientStream != nil { - clientStream = &StreamInterceptorData{ - Interface: md.ClientStream.Interface, - SendName: md.ClientStream.SendName, - SendWithContextName: md.ClientStream.SendWithContextName, - SendTypeRef: md.ClientStream.SendTypeRef, - RecvName: md.ClientStream.RecvName, - RecvWithContextName: md.ClientStream.RecvWithContextName, - RecvTypeRef: md.ClientStream.RecvTypeRef, - MustClose: md.ClientStream.MustClose, - } - } - var payloadAccess, resultAccess, streamingPayloadAccess, streamingResultAccess string - if i.ReadPayload != nil || i.WritePayload != nil { - payloadAccess = codegen.Goify(i.Name, false) + md.VarName + "Payload" - } - if i.ReadResult != nil || i.WriteResult != nil { - resultAccess = codegen.Goify(i.Name, false) + md.VarName + "Result" - } - if i.ReadStreamingPayload != nil || i.WriteStreamingPayload != nil { - streamingPayloadAccess = codegen.Goify(i.Name, false) + md.VarName + "StreamingPayload" - } - if i.ReadStreamingResult != nil || i.WriteStreamingResult != nil { - streamingResultAccess = codegen.Goify(i.Name, false) + md.VarName + "StreamingResult" - } - return &MethodInterceptorData{ - MethodName: md.VarName, - PayloadAccess: payloadAccess, - ResultAccess: resultAccess, - PayloadRef: md.PayloadRef, - ResultRef: md.ResultRef, - StreamingPayloadAccess: streamingPayloadAccess, - StreamingPayloadRef: md.StreamingPayloadRef, - StreamingResultAccess: streamingResultAccess, - StreamingResultRef: md.ResultRef, - ClientStream: clientStream, - ServerStream: serverStream, - } -} - -// BuildSchemeData builds the scheme data for the given scheme and method expr. -func BuildSchemeData(s *expr.SchemeExpr, m *expr.MethodExpr) *SchemeData { - if !expr.IsObject(m.Payload.Type) { - return nil - } - if s.Kind == expr.BasicAuthKind { - userAtt := expr.TaggedAttribute(m.Payload, "security:username") - passAtt := expr.TaggedAttribute(m.Payload, "security:password") - return &SchemeData{ - Type: s.Kind.String(), - SchemeName: s.SchemeName, - UsernameAttr: userAtt, - UsernameField: codegen.Goify(userAtt, true), - UsernamePointer: m.Payload.IsPrimitivePointer(userAtt, true), - UsernameRequired: m.Payload.IsRequired(userAtt), - PasswordAttr: passAtt, - PasswordField: codegen.Goify(passAtt, true), - PasswordPointer: m.Payload.IsPrimitivePointer(passAtt, true), - PasswordRequired: m.Payload.IsRequired(passAtt), - Scopes: schemeScopes(s), - } - } - // The remaining scheme kinds all carry a single credential attribute - // identified by a kind-specific security tag on the method payload. - var tag string - switch s.Kind { - case expr.APIKeyKind: - tag = "security:apikey:" + s.SchemeName - case expr.BearerKind: - tag = "security:bearer" - case expr.JWTKind: - tag = "security:token" - case expr.OAuth2Kind: - tag = "security:accesstoken" - default: - return nil - } - keyAtt := expr.TaggedAttribute(m.Payload, tag) - if keyAtt == "" { - return nil - } - data := &SchemeData{ - Type: s.Kind.String(), - Name: s.Name, - SchemeName: s.SchemeName, - CredField: codegen.Goify(keyAtt, true), - CredPointer: m.Payload.IsPrimitivePointer(keyAtt, true), - CredRequired: m.Payload.IsRequired(keyAtt), - KeyAttr: keyAtt, - Scopes: schemeScopes(s), - In: s.In, - } - if s.Kind == expr.OAuth2Kind { - data.Flows = s.Flows - } - return data -} - -// schemeScopes returns the scope names defined by the scheme, nil when the -// scheme defines none. -func schemeScopes(s *expr.SchemeExpr) []string { - if len(s.Scopes) == 0 { - return nil - } - scopes := make([]string, len(s.Scopes)) - for i, sc := range s.Scopes { - scopes[i] = sc.Name - } - return scopes -} - -// collectAttributes builds AttributeData from an AttributeExpr -func collectAttributes(attrNames, parent *expr.AttributeExpr, scope *codegen.NameScope) []*AttributeData { - if attrNames == nil { - return nil - } - obj := expr.AsObject(attrNames.Type) - if obj == nil { - return nil - } - data := make([]*AttributeData, len(*obj)) - for i, nat := range *obj { - parentAttr := parent.Find(nat.Name) - if parentAttr == nil { - // Attribute references are validated at design time so a miss - // here would surface as a nil deref at template render time. - panic(fmt.Sprintf("attribute %q not found in parent attribute", nat.Name)) // bug - } - var pkg string - if loc := codegen.UserTypeLocation(parentAttr.Type); loc != nil { - pkg = loc.PackageName() - } - data[i] = &AttributeData{ - Name: codegen.Goify(nat.Name, true), - TypeRef: scope.GoFullTypeRef(parentAttr, pkg), - Pointer: parent.IsPrimitivePointer(nat.Name, true), - } - } - return data -} - -// collectProjectedTypes builds a projected type for every user type found when -// recursing through the attributes. The projected types live in the views -// package and support the marshaling and unmarshalling of result types that -// make use of views. We need to build projected types for all user types - not -// just result types - because user types may contain result types and thus may -// need to be marshalled in different ways depending on the view being used. -func collectProjectedTypes(projected, att *expr.AttributeExpr, viewspkg string, scope, viewScope *codegen.NameScope, seen map[string]*ProjectedTypeData) []*ProjectedTypeData { - collect := func(projected, att *expr.AttributeExpr) []*ProjectedTypeData { - return collectProjectedTypes(projected, att, viewspkg, scope, viewScope, seen) - } - var data []*ProjectedTypeData - switch pt := projected.Type.(type) { - case expr.UserType: - dt := att.Type.(expr.UserType) - if pd, ok := seen[dt.ID()]; ok { - // a projected type is already created for this user type. We change the - // attribute type to this seen projected type. The seen projected type - // can be nil if the attribute type has a circular type definition in - // which case we don't change the attribute type until the projected type - // is created during the recursion. - if pd != nil { - projected.Type = pd.Type - } - return data - } - seen[dt.ID()] = nil - pt.Rename(pt.Name() + "View") - // We recurse before building the projected type so that user types within - // a projected type is also converted to their respective projected types. - types := collect(pt.Attribute(), dt.Attribute()) - pd := buildProjectedType(projected, att, viewspkg, scope, viewScope) - seen[dt.ID()] = pd - data = append(data, pd) - data = append(data, types...) - case *expr.Array: - dt := att.Type.(*expr.Array) - types := collect(pt.ElemType, dt.ElemType) - data = append(data, types...) - case *expr.Map: - dt := att.Type.(*expr.Map) - types := collect(pt.KeyType, dt.KeyType) - data = append(data, types...) - types = collect(pt.ElemType, dt.ElemType) - data = append(data, types...) - case *expr.Object: - dt := att.Type.(*expr.Object) - for _, n := range *pt { - types := collect(n.Attribute, dt.Attribute(n.Name)) - data = append(data, types...) - } - case *expr.Union: - dt := att.Type.(*expr.Union) - for i, n := range pt.Values { - types := collect(n.Attribute, dt.Values[i].Attribute) - data = append(data, types...) - } - } - return data -} - -// projectedResultRoot returns the root attribute used to collect projected -// view types for m.Result. NormalizeRoot synthesizes user types for raw object -// method results before service analysis; projected view collection keeps the -// pre-normalization shape by traversing those synthetic wrappers' attributes -// directly instead of generating view-local types for the wrappers themselves. -func projectedResultRoot(service *expr.ServiceExpr, m *expr.MethodExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { - if ut, ok := m.Result.Type.(*expr.UserTypeExpr); ok && ut.ID() == normalizedMethodTypeID(service, m, "Result") { - return expr.DupAtt(ut.Attribute()), ut.Attribute() - } - return expr.DupAtt(m.Result), m.Result -} - -func normalizedMethodTypeID(service *expr.ServiceExpr, m *expr.MethodExpr, suffix string) string { - return service.Name + "#" + codegen.Goify(m.Name, true) + suffix -} - -// hasResultType returns true if the given attribute has a result type recursively. -func hasResultType(att *expr.AttributeExpr, seens ...map[string]struct{}) bool { - if _, ok := att.Type.(*expr.ResultTypeExpr); ok { - return true - } - var seen map[string]struct{} - if len(seens) > 0 { - seen = seens[0] - } else { - seen = make(map[string]struct{}) - } - switch a := att.Type.(type) { - case expr.UserType: - if _, ok := seen[a.ID()]; ok { - return false - } - seen[a.ID()] = struct{}{} - return hasResultType(a.Attribute(), seen) - case *expr.Array: - return hasResultType(a.ElemType, seen) - case *expr.Map: - return hasResultType(a.KeyType, seen) || hasResultType(a.ElemType, seen) - case *expr.Object: - for _, nat := range *a { - if hasResultType(nat.Attribute, seen) { - return true - } - } - case *expr.Union: - for _, nat := range a.Values { - if hasResultType(nat.Attribute, seen) { - return true - } - } - } - return false -} - -// buildProjectedType builds projected type for the given user type. -// -// viewspkg is the name of the views package -func buildProjectedType(projected, att *expr.AttributeExpr, viewspkg string, scope, viewScope *codegen.NameScope) *ProjectedTypeData { - var ( - projections []*InitData - typeInits []*InitData - views []*ViewData - - varname = viewScope.GoTypeName(projected) - pt = projected.Type.(expr.UserType) - ) - if _, isrt := pt.(*expr.ResultTypeExpr); isrt { - typeInits = buildViewConversions(projected, att, viewspkg, scope, viewScope, true) - projections = buildViewConversions(projected, att, viewspkg, scope, viewScope, false) - views = buildViews(att.Type.(*expr.ResultTypeExpr), viewScope) - } - validations := buildValidations(projected, viewScope) - removeMeta(projected) - return &ProjectedTypeData{ - UserTypeData: &UserTypeData{ - Name: varname, - Description: fmt.Sprintf("%s is a type that runs validations on a projected type.", varname), - VarName: varname, - Def: viewScope.GoTypeDef(pt.Attribute(), true, true), - Ref: viewScope.GoTypeRef(projected), - Type: pt, - }, - Projections: projections, - TypeInits: typeInits, - Validations: validations, - ViewsPkg: viewspkg, - Views: views, - } -} - -// buildViews builds the view data for all the views in the given result type. -func buildViews(rt *expr.ResultTypeExpr, viewScope *codegen.NameScope) []*ViewData { - views := make([]*ViewData, len(rt.Views)) - for i, view := range rt.Views { - vatt := expr.AsObject(view.Type) - attrs := make([]string, len(*vatt)) - for j, nat := range *vatt { - attrs[j] = nat.Name - } - views[i] = &ViewData{ - Name: view.Name, - Description: view.Description, - Attributes: attrs, - TypeVarName: viewScope.GoTypeName(&expr.AttributeExpr{Type: rt}), - } - } - return views -} - -// buildViewedResultType builds a viewed result type from the given result type -// and projected type. -func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, scope, viewScope *codegen.NameScope) *ViewedResultTypeData { - // collect result type views - rt := att.Type.(*expr.ResultTypeExpr) - isarr := expr.IsArray(att.Type) - var viewName string - if !rt.HasMultipleViews() { - viewName = expr.DefaultView - } - if v, ok := att.Meta.Last(expr.ViewMetaKey); ok { - viewName = v - } - views := buildViews(rt, viewScope) - - // build validation data - resvar := scope.GoTypeName(att) - resref := scope.GoTypeRef(att) - data := map[string]any{ - "Projected": scope.GoTypeName(projected), - "ArgVar": "result", - "Source": "result", - "Views": views, - "IsViewed": true, - } - buf := &bytes.Buffer{} - if err := validateTypeCodeTmpl.Execute(buf, data); err != nil { - panic(err) // bug - } - name := "Validate" + resvar - validate := &ValidateData{ - Name: name, - Description: fmt.Sprintf("%s runs the validations defined on the viewed result type %s.", name, resvar), - Ref: resref, - Validate: buf.String(), - } - - // build constructor to initialize viewed result type from result type - vresref := viewScope.GoFullTypeRef(att, viewspkg) - data = map[string]any{ - "ToViewed": true, - "ArgVar": "res", - "ReturnVar": "vres", - "Views": views, - "ReturnTypeRef": vresref, - "IsCollection": isarr, - "TargetType": scope.GoFullTypeName(att, viewspkg), - "InitName": "new" + viewScope.GoTypeName(projected), - } - buf = &bytes.Buffer{} - if err := initTypeCodeTmpl.Execute(buf, data); err != nil { - panic(err) // bug - } - pkg := "" - if loc := codegen.UserTypeLocation(att.Type); loc != nil { - pkg = loc.PackageName() - } - name = "NewViewed" + resvar - init := &InitData{ - Name: name, - Description: fmt.Sprintf("%s initializes viewed result type %s from result type %s using the given view.", name, resvar, resvar), - Args: []*InitArgData{ - {Name: "res", Ref: scope.GoFullTypeRef(att, pkg)}, - {Name: "view", Ref: "string"}, - }, - ReturnTypeRef: vresref, - Code: buf.String(), - } - - // build constructor to initialize result type from viewed result type - if loc := codegen.UserTypeLocation(att.Type); loc != nil { - resref = scope.GoFullTypeRef(att, loc.PackageName()) - } - data = map[string]any{ - "ToResult": true, - "ArgVar": "vres", - "ReturnVar": "res", - "Views": views, - "ReturnTypeRef": resref, - "InitName": "new" + scope.GoTypeName(att), - } - buf = &bytes.Buffer{} - if err := initTypeCodeTmpl.Execute(buf, data); err != nil { - panic(err) // bug - } - name = "New" + resvar - resinit := &InitData{ - Name: name, - Description: fmt.Sprintf("%s initializes result type %s from viewed result type %s.", name, resvar, resvar), - Args: []*InitArgData{{Name: "vres", Ref: scope.GoFullTypeRef(att, viewspkg)}}, - ReturnTypeRef: resref, - Code: buf.String(), - } - - projT := wrapProjected(projected.Type.(expr.UserType)) - return &ViewedResultTypeData{ - UserTypeData: &UserTypeData{ - Name: resvar, - Description: fmt.Sprintf("%s is the viewed result type that is projected based on a view.", resvar), - VarName: resvar, - Def: viewScope.GoTypeDef(projT.Attribute(), false, true), - Ref: resref, - Type: projT, - }, - FullName: scope.GoFullTypeName(att, viewspkg), - FullRef: vresref, - ResultInit: resinit, - Init: init, - Views: views, - Validate: validate, - IsCollection: isarr, - ViewName: viewName, - ViewsPkg: viewspkg, - } -} - -// wrapProjected builds a viewed result type by wrapping the given projected -// in a result type with "projected" and "view" attributes. -func wrapProjected(projected expr.UserType) expr.UserType { - rt := projected.(*expr.ResultTypeExpr) - pratt := &expr.NamedAttributeExpr{ - Name: "projected", - Attribute: &expr.AttributeExpr{Type: rt, Description: "Type to project"}, - } - prview := &expr.NamedAttributeExpr{ - Name: "view", - Attribute: &expr.AttributeExpr{Type: expr.String, Description: "View to render"}, - } - return &expr.ResultTypeExpr{ - UserTypeExpr: &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{ - Type: &expr.Object{pratt, prview}, - Validation: &expr.ValidationExpr{Required: []string{"projected", "view"}}, - }, - TypeName: rt.TypeName, - }, - Identifier: rt.Identifier, - Views: rt.Views, - } -} - -// buildViewConversions builds the data to generate the constructor code that -// converts between a result type and its projected type, one constructor per -// view. When toResult is true the constructors initialize the result type from -// the projected type, otherwise they project the result type to the projected -// type based on the view. -func buildViewConversions(projected, att *expr.AttributeExpr, viewspkg string, scope, viewScope *codegen.NameScope, toResult bool) []*InitData { - vrt := att.Type.(*expr.ResultTypeExpr) - if toResult { - vrt = projected.Type.(*expr.ResultTypeExpr) - } - pobj := expr.AsObject(projected.Type) - parr := expr.AsArray(projected.Type) - if parr != nil { - // result type collection - pobj = expr.AsObject(parr.ElemType.Type) - } - - init := make([]*InitData, 0, len(vrt.Views)) - for _, view := range vrt.Views { - var typ expr.DataType - obj := &expr.Object{} - walkViewAttrs(pobj, view, func(name string, att, _ *expr.AttributeExpr) { - obj.Set(name, att) - }) - typ = obj - if parr != nil { - ename := parr.ElemType.Type.Name() - if toResult { - ename = scope.GoTypeName(parr.ElemType) - } - typ = &expr.Array{ElemType: &expr.AttributeExpr{ - Type: &expr.ResultTypeExpr{ - UserTypeExpr: &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{Type: obj}, - TypeName: ename, - }, - }, - }} - } - wname := projected.Type.Name() - if toResult { - wname = scope.GoTypeName(projected) - } - // viewed is the projected type narrowed down to the view attributes. - viewed := &expr.AttributeExpr{ - Type: &expr.ResultTypeExpr{ - UserTypeExpr: &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{Type: typ}, - TypeName: wname, - }, - Views: vrt.Views, - Identifier: vrt.Identifier, - }, - } - - pkg := "" - if loc := codegen.UserTypeLocation(att.Type); loc != nil { - pkg = loc.PackageName() - } - if toResult { - srcCtx := projectedTypeContext(viewspkg, true, viewScope) - tgtCtx := typeContext(scope) - resvar := scope.GoTypeName(att) - name := "new" + resvar - if view.Name != expr.DefaultView { - name += codegen.Goify(view.Name, true) - } - code, helpers := buildConstructorCode(viewed, att, "vres", "res", srcCtx, tgtCtx, view.Name) - init = append(init, &InitData{ - Name: name, - Description: fmt.Sprintf("%s converts projected type %s to service type %s.", name, resvar, resvar), - Args: []*InitArgData{{Name: "vres", Ref: viewScope.GoFullTypeRef(projected, viewspkg)}}, - ReturnTypeRef: scope.GoFullTypeRef(att, pkg), - Code: code, - Helpers: helpers, - }) - } else { - srcCtx := typeContext(scope) - tgtCtx := projectedTypeContext(viewspkg, true, viewScope) - tname := scope.GoTypeName(projected) - name := "new" + tname - if view.Name != expr.DefaultView { - name += codegen.Goify(view.Name, true) - } - code, helpers := buildConstructorCode(att, viewed, "res", "vres", srcCtx, tgtCtx, view.Name) - init = append(init, &InitData{ - Name: name, - Description: fmt.Sprintf("%s projects result type %s to projected type %s using the %q view.", name, scope.GoTypeName(att), tname, view.Name), - Args: []*InitArgData{{Name: "res", Ref: scope.GoFullTypeRef(att, pkg)}}, - ReturnTypeRef: viewScope.GoFullTypeRef(projected, viewspkg), - Code: code, - Helpers: helpers, - }) - } - } - return init -} - -// buildValidations builds the data required to generate validations for the -// projected types. -func buildValidations(projected *expr.AttributeExpr, scope *codegen.NameScope) []*ValidateData { - ut := projected.Type.(expr.UserType) - tname := scope.GoTypeName(projected) - var validations []*ValidateData - if rt, isrt := ut.(*expr.ResultTypeExpr); isrt { - // for result types we create a validation function containing view - // specific validation logic for each view - arr := expr.AsArray(projected.Type) - for _, view := range rt.Views { - data := map[string]any{ - "Projected": tname, - "ArgVar": "result", - "Source": "result", - "IsCollection": arr != nil, - } - var vn string - name := "Validate" + tname - if view.Name != expr.DefaultView { - vn = codegen.Goify(view.Name, true) - name += vn - } - - if arr != nil { - // dealing with an array type - data["Source"] = "item" - data["ValidateVar"] = "Validate" + scope.GoTypeName(arr.ElemType) + vn - } else { - var fields []map[string]any - o := &expr.Object{} - walkViewAttrs(expr.AsObject(projected.Type), view, func(name string, attr, vatt *expr.AttributeExpr) { - if rt, ok := attr.Type.(*expr.ResultTypeExpr); ok { - // use explicitly specified view (if any) for the attribute, - // otherwise use default - vw := "" - if v, ok := vatt.Meta.Last(expr.ViewMetaKey); ok && v != expr.DefaultView { - vw = v - } - fields = append(fields, map[string]any{ - "Name": name, - "ValidateVar": "Validate" + scope.GoTypeName(attr) + codegen.Goify(vw, true), - "IsRequired": rt.Attribute().IsRequired(name), - }) - } else { - o.Set(name, attr) - } - }) - ctx := projectedTypeContext("", !expr.IsPrimitive(projected.Type), scope) - data["Validate"] = codegen.ValidationCode(&expr.AttributeExpr{Type: o, Validation: rt.Validation}, rt, ctx, true, false, true, "result") - data["Fields"] = fields - } - - buf := &bytes.Buffer{} - if err := validateTypeCodeTmpl.Execute(buf, data); err != nil { - panic(err) // bug - } - - validations = append(validations, &ValidateData{ - Name: name, - Description: fmt.Sprintf("%s runs the validations defined on %s using the %q view.", name, tname, view.Name), - Ref: scope.GoTypeRef(projected), - Validate: buf.String(), - }) - } - } else { - // for a user type or a result type with single view, we generate only one validation - // function containing the validation logic - name := "Validate" + tname - ctx := projectedTypeContext("", !expr.IsPrimitive(projected.Type), scope) - validations = append(validations, &ValidateData{ - Name: name, - Description: fmt.Sprintf("%s runs the validations defined on %s.", name, tname), - Ref: scope.GoTypeRef(projected), - Validate: codegen.ValidationCode(ut.Attribute(), ut, ctx, true, expr.IsAlias(ut), true, "result"), - }) - } - return validations -} - -// buildConstructorCode builds the transformation code to create a projected -// type from a service type and vice versa. -// -// source and target contains the projected/service contextual attributes -// -// sourceVar and targetVar contains the variable name that holds the source and -// target data structures in the transformation code. -// -// view is used to generate the constructor function name. -func buildConstructorCode(src, tgt *expr.AttributeExpr, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext, view string) (string, []*codegen.TransformFunctionData) { - var ( - helpers []*codegen.TransformFunctionData - buf bytes.Buffer - ) - rt := src.Type.(*expr.ResultTypeExpr) - arr := expr.AsArray(tgt.Type) - - data := map[string]any{ - "ArgVar": sourceVar, - "ReturnVar": targetVar, - "IsCollection": arr != nil, - "TargetType": targetCtx.Scope.Name(tgt, targetCtx.Pkg(tgt), targetCtx.Pointer, targetCtx.UseDefault), - } - - if arr != nil { - // result type collection - init := "new" + targetCtx.Scope.Name(arr.ElemType, "", targetCtx.Pointer, targetCtx.UseDefault) - if view != "" && view != expr.DefaultView { - init += codegen.Goify(view, true) - } - data["InitName"] = init - if err := initTypeCodeTmpl.Execute(&buf, data); err != nil { - panic(err) // bug - } - return buf.String(), helpers - } - - // service type to projected type (or vice versa) - targetRTs := &expr.Object{} - tatt := expr.DupAtt(tgt) - tobj := expr.AsObject(tatt.Type) - for _, nat := range *tobj { - if _, ok := nat.Attribute.Type.(*expr.ResultTypeExpr); ok { - targetRTs.Set(nat.Name, nat.Attribute) - tobj.Delete(nat.Name) - } - } - data["Source"] = sourceVar - data["Target"] = targetVar - - // build code for target with no result types - code, helpers, err := codegen.GoTransform(src, tatt, sourceVar, targetVar, sourceCtx, targetCtx, "transform", true) - if err != nil { - panic(err) // bug - } - data["Code"] = code - - if view != "" { - data["InitName"] = targetCtx.Scope.Name(src, "", targetCtx.Pointer, targetCtx.UseDefault) - } - fields := make([]map[string]any, 0, len(*targetRTs)) - // iterate through the result types found in the target and add the - // code to initialize them - for _, nat := range *targetRTs { - finit := "new" + targetCtx.Scope.Name(nat.Attribute, "", targetCtx.Pointer, targetCtx.UseDefault) - if view != "" { - v := "" - if vatt := rt.View(view).Find(nat.Name); vatt != nil { - if attv, ok := vatt.Meta.Last(expr.ViewMetaKey); ok && attv != expr.DefaultView { - // view is explicitly set for the result type on the attribute - v = attv - } - } - finit += codegen.Goify(v, true) - } - fields = append(fields, map[string]any{ - "VarName": codegen.Goify(nat.Name, true), - "FieldInit": finit, - }) - } - data["Fields"] = fields - - if err := initTypeCodeTmpl.Execute(&buf, data); err != nil { - panic(err) // bug - } - return buf.String(), helpers -} - -// walkViewAttrs iterates through the attributes in att that are found in the -// given view and executes the walker function. -func walkViewAttrs(obj *expr.Object, view *expr.ViewExpr, walker func(name string, attr, vatt *expr.AttributeExpr)) { - for _, nat := range *expr.AsObject(view.Type) { - if attr := obj.Attribute(nat.Name); attr != nil { - walker(nat.Name, attr, nat.Attribute) - } - } -} - -// removeMeta removes the meta attributes from the given attribute. This is -// needed to make sure that any field name overriding is removed when -// generating protobuf types (as protogen itself won't honor these overrides). -func removeMeta(att *expr.AttributeExpr) { - _ = codegen.Walk(att, func(a *expr.AttributeExpr) error { - delete(a.Meta, "struct:pkg:path") - return nil - }) -} diff --git a/codegen/service/service_data_union_nilability_test.go b/codegen/service/service_data_union_nilability_test.go index 2db6834805..2fdaa9e84b 100644 --- a/codegen/service/service_data_union_nilability_test.go +++ b/codegen/service/service_data_union_nilability_test.go @@ -1,9 +1,12 @@ +// This file verifies pointer/value semantics recorded for generated union +// branch fields. package service import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" @@ -11,12 +14,21 @@ import ( func TestBuildUnionTypeDataMarksNilableBranches(t *testing.T) { union := unionWithBranchTypes() - data := buildUnionTypeData( - union, - codegen.NewNameScope(), - &codegen.Location{RelImportPath: "gen/service"}, - false, - ) + generation := mustTestGeneration(t, "gen", nil) + pkg := mustClaimTestPackage(t, generation, "gen/service") + declaration, err := pkg.DeclareUnion(union) + require.NoError(t, err) + facts := &unionFacts{ + union: union, + identity: codegen.NewUnionTypeID(union), + typeKey: union.GetTypeKey(), + valueKey: union.GetValueKey(), + location: &codegen.Location{RelImportPath: "gen/service"}, + declaration: declaration, + } + require.NoError(t, planUnionRenderFacts(facts, nil, pkg)) + require.NoError(t, generation.Freeze()) + data := buildRetainedUnionTypeData(facts, &importAliases{generation: generation}) nilable := make(map[string]bool, len(data.Fields)) for _, field := range data.Fields { diff --git a/codegen/service/service_data_union_order_test.go b/codegen/service/service_data_union_order_test.go index a673bce1c6..ab0d9b5f48 100644 --- a/codegen/service/service_data_union_order_test.go +++ b/codegen/service/service_data_union_order_test.go @@ -1,92 +1,68 @@ +// This file verifies retained service union declarations keep deterministic +// names regardless of design traversal order. package service import ( + "sort" + "strings" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" + "goa.design/goa/v3/dsl" ) -func TestCollectUnionTypesDeterministicAcrossObjectOrder(t *testing.T) { - sourceFromSignals := makeUnionForOrderTest("source", - "physical_point", - "synthetic_series", - ) - sourceFromInputs := makeUnionForOrderTest("source", - "time_series", - "energy_rates", - ) +func TestServicePlanUnionNamesAreIndependentOfObjectOrder(t *testing.T) { + forward := retainedUnionNames(t, false) + reverse := retainedUnionNames(t, true) - forward := &expr.AttributeExpr{ - Type: &expr.Object{ - { - Name: "alpha", - Attribute: &expr.AttributeExpr{ - Type: sourceFromSignals, - }, - }, - { - Name: "beta", - Attribute: &expr.AttributeExpr{ - Type: sourceFromInputs, - }, - }, - }, - } - reverse := &expr.AttributeExpr{ - Type: &expr.Object{ - { - Name: "beta", - Attribute: &expr.AttributeExpr{ - Type: sourceFromInputs, - }, - }, - { - Name: "alpha", - Attribute: &expr.AttributeExpr{ - Type: sourceFromSignals, - }, - }, - }, - } - - loc := &codegen.Location{ - RelImportPath: "gen/service", - } - forwardNames := collectServiceUnionTypeNames(forward, loc) - reverseNames := collectServiceUnionTypeNames(reverse, loc) - - require.Len(t, forwardNames, 2) - require.Equal(t, forwardNames, reverseNames) -} - -func collectServiceUnionTypeNames(att *expr.AttributeExpr, loc *codegen.Location) map[string]string { - scope := codegen.NewNameScope() - seen := make(map[string]struct{}) - unionByHash := make(map[string]*UnionTypeData) - collectUnionTypes(att, scope, loc, unionByHash, seen, false) - - names := make(map[string]string, len(unionByHash)) - for hash, data := range unionByHash { - names[hash] = data.Name - } - return names + require.Len(t, forward, 2) + require.Equal(t, forward, reverse) } -func makeUnionForOrderTest(typeName string, variants ...string) *expr.Union { - values := make([]*expr.NamedAttributeExpr, len(variants)) - for i, variant := range variants { - values[i] = &expr.NamedAttributeExpr{ - Name: variant, - Attribute: &expr.AttributeExpr{ - Type: expr.String, - }, +// retainedUnionNames plans two same-base unions in the requested field order +// and indexes their frozen names by their ordered branch contract. +func retainedUnionNames(t *testing.T, reverse bool) map[string]string { + t.Helper() + root := codegen.RunDSL(t, func() { + signals := dsl.Type("Signals", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("source", func() { + dsl.Attribute("physical_point", dsl.String) + dsl.Attribute("synthetic_series", dsl.String) + }) + }) + inputs := dsl.Type("Inputs", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("source", func() { + dsl.Attribute("time_series", dsl.String) + dsl.Attribute("energy_rates", dsl.String) + }) + }) + dsl.Service("test", func() { + dsl.Method("read", func() { + dsl.Payload(func() { + if reverse { + dsl.Attribute("beta", inputs) + dsl.Attribute("alpha", signals) + } else { + dsl.Attribute("alpha", signals) + dsl.Attribute("beta", inputs) + } + }) + }) + }) + }) + plan := mustServicePlan(t, root) + names := make(map[string]string) + for _, union := range plan.Services().Get("test").unions { + branches := make([]string, len(union.Fields)) + for index, field := range union.Fields { + branches[index] = field.Name } + sort.Strings(branches) + names[strings.Join(branches, ",")] = union.Name } - return &expr.Union{ - TypeName: typeName, - Values: values, - } + return names } diff --git a/codegen/service/service_declaration_condition_contract_test.go b/codegen/service/service_declaration_condition_contract_test.go new file mode 100644 index 0000000000..0d5c48904d --- /dev/null +++ b/codegen/service/service_declaration_condition_contract_test.go @@ -0,0 +1,45 @@ +// This file verifies service package declarations are collected only for the +// conditions that emit them and never depend on declarations in other packages. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestRelocatedResultViewConstructorsCompile catches constructor declarations +// that incorrectly depend on a result type declaration owned by another Go +// package. +func TestRelocatedResultViewConstructorsCompile(t *testing.T) { + root := codegen.RunDSL(t, func() { + reading := dsl.ResultType("application/vnd.reading", func() { + dsl.TypeName("Reading") + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("name", dsl.String) + dsl.Attribute("value", dsl.Int) + dsl.Required("name", "value") + dsl.View("default", func() { + dsl.Attribute("name") + dsl.Attribute("value") + }) + dsl.View("summary", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(reading) + }) + }) + }) + + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, files) +} diff --git a/codegen/service/service_dedup_test.go b/codegen/service/service_dedup_test.go deleted file mode 100644 index 3ddf22599e..0000000000 --- a/codegen/service/service_dedup_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package service - -import ( - "bytes" - "strings" - "testing" - - "github.com/stretchr/testify/require" - - "goa.design/goa/v3/codegen" - stest "goa.design/goa/v3/codegen/service/testdata" -) - -// TestService_DedupEventMarkers verifies that when multiple streaming methods share the -// same result type the generated service code only emits a single event marker method. -func TestService_DedupEventMarkers(t *testing.T) { - root := codegen.RunDSL(t, stest.StreamingDuplicateResultTypesDSL) - services := NewServicesData(root) - require.Len(t, root.Services, 1) - - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) - require.Greater(t, len(files), 0) - - // Generate the service.go content - buf := new(bytes.Buffer) - for _, s := range files[0].SectionTemplates[1:] { - require.NoError(t, s.Write(buf)) - } - code := buf.String() - - // Count occurrences of the service-level event marker method for SharedEvent - // The marker has the shape: func (*SharedEvent) isdupStreamServiceEvent() {} - occurrences := strings.Count(code, "func (*SharedEvent) isdupStreamServiceEvent()") - require.Equal(t, 1, occurrences, "expected a single event marker for SharedEvent, got %d", occurrences) -} diff --git a/codegen/service/service_fact_plan.go b/codegen/service/service_fact_plan.go new file mode 100644 index 0000000000..68727a00f1 --- /dev/null +++ b/codegen/service/service_fact_plan.go @@ -0,0 +1,307 @@ +// This file copies the methods, errors, stream settings, and interceptors used +// by one service before generated Go names are chosen. +package service + +import ( + "sort" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// collectServiceFacts copies the service fields and transport choices needed by +// templates, so later steps do not walk design collections that plugins could +// change. +func collectServiceFacts(root *expr.RootExpr, service *expr.ServiceExpr, examples *expr.ExampleGenerator) *serviceFacts { + facts := &serviceFacts{ + service: service, + apiName: root.API.Name, + name: service.Name, + description: service.Description, + methods: append([]*expr.MethodExpr(nil), service.Methods...), + methodByExpr: make(map[*expr.MethodExpr]*methodFacts, len(service.Methods)), + errors: append([]*expr.ErrorExpr(nil), service.Errors...), + reachableTypes: make(map[expr.UserType]struct{}), + projections: make(map[*expr.MethodExpr]*projectionFacts), + } + for _, serviceError := range facts.errors { + facts.errorFacts = append(facts.errorFacts, retainErrorRenderFacts(serviceError)) + facts.referenceAttributes = append(facts.referenceAttributes, serviceError.AttributeExpr) + retainServiceValueTypes(facts, serviceError.AttributeExpr) + } + methodScope := codegen.NewNameScope() + methodScope.Unique("Use") + methodScope.Unique("websocket") + for _, method := range service.Methods { + methodFacts := &methodFacts{ + method: method, + serviceName: service.Name, + name: method.Name, + description: method.Description, + idempotent: method.Idempotent, + payload: retainMethodAttribute(method.Payload, examples.At(expr.MethodPayloadExampleIdentity(method))), + result: retainMethodAttribute(method.Result, examples.At(expr.MethodResultExampleIdentity(method))), + streamKind: method.Stream, + isStreaming: method.IsStreaming(), + hasMixedResults: method.HasMixedResults(), + varName: methodScope.Unique(codegen.Goify(method.Name, true), "Endpoint"), + } + methodFacts.streamingPayload = retainMethodAttribute( + method.StreamingPayload, + examples.At(expr.MethodStreamingPayloadExampleIdentity(method)), + ) + methodFacts.streamingResult = retainMethodAttribute( + method.StreamingResult, + examples.At(expr.MethodStreamingResultExampleIdentity(method)), + ) + methodFacts.requirements, methodFacts.schemes = retainMethodSecurity(method) + for _, methodError := range method.Errors { + methodFacts.errors = append(methodFacts.errors, retainErrorRenderFacts(methodError)) + } + if method.IsStreaming() || method.HasMixedResults() { + methodFacts.serverStreamVarName = methodScope.Unique(codegen.Goify(method.Name, true), "ServerStream") + methodFacts.clientStreamVarName = methodScope.Unique(codegen.Goify(method.Name, true), "ClientStream") + } + for _, httpService := range root.API.HTTP.Services { + if httpService.Name() != service.Name { + continue + } + if endpoint := httpService.Endpoint(method.Name); endpoint != nil { + methodFacts.skipRequestBodyEncodeDecode = endpoint.SkipRequestBodyEncodeDecode + methodFacts.skipResponseBodyEncodeDecode = endpoint.SkipResponseBodyEncodeDecode + } + break + } + facts.methodByExpr[method] = methodFacts + facts.orderedMethods = append(facts.orderedMethods, methodFacts) + facts.referenceAttributes = append( + facts.referenceAttributes, + method.Payload, + method.StreamingPayload, + method.Result, + ) + retainServiceValueTypes(facts, method.Payload) + retainServiceValueTypes(facts, method.StreamingPayload) + retainServiceValueTypes(facts, method.Result) + if method.HasMixedResults() { + facts.referenceAttributes = append(facts.referenceAttributes, method.StreamingResult) + retainServiceValueTypes(facts, method.StreamingResult) + } + for _, methodError := range method.Errors { + facts.referenceAttributes = append(facts.referenceAttributes, methodError.AttributeExpr) + retainServiceValueTypes(facts, methodError.AttributeExpr) + } + } + for _, method := range facts.methods { + methodFacts := facts.methodByExpr[method] + methodFacts.endpointField = methodScope.Unique(methodFacts.varName+"Endpoint", "") + if method.HasMixedResults() { + methodFacts.streamEndpointField = methodScope.Unique(methodFacts.varName+"StreamEndpoint", "") + } + } + facts.serverInterceptors = retainedInterceptors(root.API.ServerInterceptors, service.ServerInterceptors, facts.methods, true) + facts.clientInterceptors = retainedInterceptors(root.API.ClientInterceptors, service.ClientInterceptors, facts.methods, false) + facts.serverInterceptorFacts = collectInterceptorFacts(facts.serverInterceptors, facts.methods, facts.methodByExpr, true) + facts.clientInterceptorFacts = collectInterceptorFacts(facts.clientInterceptors, facts.methods, facts.methodByExpr, false) + return facts +} + +// retainServiceValueTypes records every named type reachable from a payload, +// result, error, or stream value. User-supplied Go type mappings use this set to +// generate conversions for all four kinds of service data. +func retainServiceValueTypes(facts *serviceFacts, attribute *expr.AttributeExpr) { + if attribute == nil || attribute.Type == expr.Empty { + return + } + err := codegen.Walk(attribute, func(attribute *expr.AttributeExpr) error { + if userType, ok := attribute.Type.(expr.UserType); ok { + facts.reachableTypes[userType.Origin()] = struct{}{} + } + return nil + }) + if err != nil { + panic(err) // the collector callback cannot return an error + } +} + +// collectInterceptorFacts records the methods that call each interceptor, so +// template data can be built without walking the design again. +func collectInterceptorFacts(interceptors []*expr.InterceptorExpr, methods []*expr.MethodExpr, methodFacts map[*expr.MethodExpr]*methodFacts, server bool) []*interceptorFacts { + result := make([]*interceptorFacts, len(interceptors)) + for index, interceptor := range interceptors { + facts := &interceptorFacts{ + name: interceptor.Name, + description: interceptor.Description, + readPayload: interceptor.ReadPayload, + writePayload: interceptor.WritePayload, + readResult: interceptor.ReadResult, + writeResult: interceptor.WriteResult, + readStreamingPayload: interceptor.ReadStreamingPayload, + writeStreamingPayload: interceptor.WriteStreamingPayload, + readStreamingResult: interceptor.ReadStreamingResult, + writeStreamingResult: interceptor.WriteStreamingResult, + } + for _, method := range methods { + applied := method.ClientInterceptors + if server { + applied = method.ServerInterceptors + } + if interceptorNamed(applied, interceptor.Name) { + facts.methods = append(facts.methods, methodFacts[method]) + } + } + result[index] = facts + } + return result +} + +// retainMethodAttribute copies one payload or result's description, metadata, +// default, and example. GoTypePlan separately records its nested Go fields. +func retainMethodAttribute(attribute *expr.AttributeExpr, examples *expr.ExampleGenerator) *methodAttributeFacts { + if attribute == nil { + return nil + } + retained := *attribute + if attribute.Meta != nil { + retained.Meta = attribute.Meta.Dup() + } + return &methodAttributeFacts{ + attribute: &retained, + present: attribute.Type != expr.Empty, + isObject: expr.IsObject(attribute.Type), + location: codegen.UserTypeLocation(attribute.Type), + description: attribute.Description, + defaultValue: cloneRetainedValue(attribute.DefaultValue), + example: cloneRetainedValue(attribute.Example(examples)), + } +} + +// retainErrorRenderFacts copies the error description, type, output location, +// and temporary, timeout, and fault settings used by generated constructors +// and client comments. +func retainErrorRenderFacts(errorExpression *expr.ErrorExpr) *errorRenderFacts { + _, temporary := errorExpression.Meta["goa:error:temporary"] + _, timeout := errorExpression.Meta["goa:error:timeout"] + _, fault := errorExpression.Meta["goa:error:fault"] + attribute := *errorExpression.AttributeExpr + if errorExpression.Meta != nil { + attribute.Meta = errorExpression.Meta.Dup() + } + return &errorRenderFacts{ + attribute: &attribute, + name: errorExpression.Name, + description: errorExpression.Description, + location: codegen.UserTypeLocation(errorExpression.Type), + temporary: temporary, + timeout: timeout, + fault: fault, + serviceType: expr.IsErrorResult(errorExpression.Type), + } +} + +// retainMethodSecurity copies credential fields and required authorization +// scope names from the evaluated method before template data is built. +func retainMethodSecurity(method *expr.MethodExpr) (RequirementsData, SchemesData) { + requirements := make(RequirementsData, 0, len(method.Requirements)) + var schemes SchemesData + for _, requirement := range expr.EffectiveSecurityRequirements(method.Requirements) { + var requirementSchemes SchemesData + for _, scheme := range requirement.Schemes { + data := cloneSchemeData(BuildSchemeData(scheme, method)) + requirementSchemes = requirementSchemes.Append(data) + schemes = schemes.Append(data) + } + requirements = append(requirements, &RequirementData{ + Schemes: requirementSchemes, + Scopes: append([]string(nil), requirement.Scopes...), + }) + } + return requirements, schemes +} + +// cloneSchemeData copies one security scheme and its slices so later changes to +// the design cannot change generated template data. +func cloneSchemeData(source *SchemeData) *SchemeData { + if source == nil { + return nil + } + cloned := *source + cloned.Scopes = append([]string(nil), source.Scopes...) + cloned.Flows = make([]*expr.FlowExpr, len(source.Flows)) + for index, flow := range source.Flows { + copy := *flow + cloned.Flows[index] = © + } + return &cloned +} + +// This helper copies maps and slices used in Goa examples and defaults. +// Numbers, strings, booleans, and other value types may be shared. +func cloneRetainedValue(source any) any { + switch actual := source.(type) { + case expr.Val: + cloned := make(expr.Val, len(actual)) + for name, value := range actual { + cloned[name] = cloneRetainedValue(value) + } + return cloned + case expr.ArrayVal: + cloned := make(expr.ArrayVal, len(actual)) + for index, value := range actual { + cloned[index] = cloneRetainedValue(value) + } + return cloned + case expr.MapVal: + cloned := make(expr.MapVal, len(actual)) + for key, value := range actual { + cloned[cloneRetainedValue(key)] = cloneRetainedValue(value) + } + return cloned + case []any: + cloned := make([]any, len(actual)) + for index, value := range actual { + cloned[index] = cloneRetainedValue(value) + } + return cloned + case []byte: + return append([]byte(nil), actual...) + case map[string]any: + cloned := make(map[string]any, len(actual)) + for name, value := range actual { + cloned[name] = cloneRetainedValue(value) + } + return cloned + case map[any]any: + cloned := make(map[any]any, len(actual)) + for key, value := range actual { + cloned[cloneRetainedValue(key)] = cloneRetainedValue(value) + } + return cloned + default: + return actual + } +} + +// This helper returns each applicable interceptor once, sorted by name, without +// changing a slice stored in the design. +func retainedInterceptors(api, service []*expr.InterceptorExpr, methods []*expr.MethodExpr, server bool) []*expr.InterceptorExpr { + interceptors := append([]*expr.InterceptorExpr(nil), api...) + interceptors = append(interceptors, service...) + for _, method := range methods { + if server { + interceptors = append(interceptors, method.ServerInterceptors...) + } else { + interceptors = append(interceptors, method.ClientInterceptors...) + } + } + sort.Slice(interceptors, func(i, j int) bool { + return interceptors[i].Name < interceptors[j].Name + }) + result := interceptors[:0] + for _, interceptor := range interceptors { + if len(result) == 0 || result[len(result)-1].Name != interceptor.Name { + result = append(result, interceptor) + } + } + return result +} diff --git a/codegen/service/service_link.go b/codegen/service/service_link.go new file mode 100644 index 0000000000..5da7f38a34 --- /dev/null +++ b/codegen/service/service_link.go @@ -0,0 +1,421 @@ +// This file turns stored service information into the data used by templates +// after all generated names and imported package names are final. +package service + +import ( + "fmt" + "path" + "slices" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// Link reads the final Go declaration and import names and builds the data used +// by service templates. Generation.Freeze must run first so each definition +// and every reference to it use the same name. +func (p *Plan) Link() error { + if !p.generation.Frozen() { + return fmt.Errorf("service plan cannot link before generation freeze") + } + if p.services != nil { + return fmt.Errorf("service plan is already linked") + } + aliases, err := newImportAliases(p.facts.root, p.generation) + if err != nil { + return err + } + for _, facts := range p.facts.services { + linkServiceFileImports(facts, p.generation) + } + if err := linkExternalConversions(p.facts, p.generation, aliases); err != nil { + return err + } + services, err := linkServicesData(p.facts, p.generation, aliases) + if err != nil { + return err + } + p.services = services + return nil +} + +// Services returns the service data passed to templates. It panics before Link +// because that data does not exist until the final Go names are available. +func (p *Plan) Services() *ServicesData { + if p.services == nil { + panic("service render model requested before plan linking") + } + return p.services +} + +// analyze creates the data necessary to render the code of the given service. +// It records the user types needed by the service definition in userTypes. +func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { + var ( + types []*UserTypeData + errTypes []*UserTypeData + errorInits []*ErrorInitData + projTypes []*ProjectedTypeData + viewedRTs []*ViewedResultTypeData + ) + servicePackage := d.generation.Package(facts.packagePath) + scope := servicePackage.Scope() + viewScope := d.generation.Package( + facts.viewsPath, + ).Scope() + pkgName := strings.ToLower(codegen.Goify(path.Base(servicePackage.ImportPath()), false)) + var viewsPkg string + seenErrors := make(map[string]struct{}) + type viewedResultKey struct { + origin expr.UserType + view string + } + seenViewed := make(map[viewedResultKey]*ViewedResultTypeData) + seenViewedDeclarations := make(map[*codegen.TypeDeclaration]struct{}) + viewDerived := make(map[expr.UserType]codegen.DerivedTypeID) + serviceResolver := newServiceResolver( + d.generation, + d.aliases, + facts.name, + facts.packagePath, + facts.packagePath, + ).withValidators(facts.validators) + types = formatUserTypeFacts(facts.userTypes, d.aliases) + errTypes = formatUserTypeFacts(facts.errorTypes, d.aliases) + + // recordError formats each selected ErrorResult constructor once. + recordError := func(errorFacts *errorRenderFacts) { + if errorFacts.serviceType { + if _, ok := seenErrors[errorFacts.name]; ok { + return + } + seenErrors[errorFacts.name] = struct{}{} + errorInits = append(errorInits, buildRetainedErrorInitData( + errorFacts, + serviceResolver, + facts.errorConstructors[errorFacts.name], + )) + } + } + for _, errorFacts := range facts.errorFacts { + recordError(errorFacts) + } + + for _, method := range facts.orderedMethods { + // Build template data for each result type containing only a view's fields. + if projection := method.projection; projection != nil { + viewsPkg = d.aliases.spec(facts.packagePath, facts.viewsPath).Name + views := d.generation.Package(facts.viewsPath) + for _, projectedFacts := range projection.types { + pair := projectedFacts.pair + identity := codegen.NewProjectedTypeID(pair.source) + viewDerived[pair.projected.Origin()] = identity + } + viewResolver := newViewResolver( + d.generation, + d.aliases, + facts.name, + facts.viewsPath, + viewDerived, + ). + withValidators(facts.validators) + for _, projectedFacts := range projection.types { + pair := projectedFacts.pair + identity := codegen.NewProjectedTypeID(pair.source) + declaration, err := views.DerivedType(identity) + if err != nil { + return nil, err + } + projectedType := buildProjectedType( + projectedFacts, + serviceResolver, + viewResolver, + declaration, + viewsPkg, + ) + projTypes = append(projTypes, projectedType) + } + } + for _, errorFacts := range method.errors { + recordError(errorFacts) + } + } + viewUnions := d.formatViewUnions(facts) + + var ( + methods []*MethodData + schemes SchemesData + ) + methods = make([]*MethodData, len(facts.orderedMethods)) + methodDataByFacts := make(map[*methodFacts]*MethodData, len(facts.orderedMethods)) + for i, method := range facts.orderedMethods { + m := buildMethodData(method, serviceResolver, facts) + methods[i] = m + methodDataByFacts[method] = m + for _, s := range m.Schemes { + schemes = schemes.Append(s) + } + viewedFacts := method.viewedResult + if viewedFacts == nil { + continue + } + viewsPkg = d.aliases.spec(facts.packagePath, facts.viewsPath).Name + key := viewedResultKey{origin: viewedFacts.origin, view: viewedFacts.viewName} + if vrt, ok := seenViewed[key]; ok { + m.ViewedResult = vrt + continue + } + vrt := buildViewedResultType( + viewedFacts, + viewsPkg, + serviceResolver, + newViewResolver( + d.generation, + d.aliases, + facts.name, + facts.viewsPath, + viewDerived, + ). + withValidators(facts.validators), + viewedFacts.declaration, + ) + if _, found := seenViewedDeclarations[viewedFacts.declaration]; !found { + viewedRTs = append(viewedRTs, vrt) + seenViewedDeclarations[viewedFacts.declaration] = struct{}{} + } + m.ViewedResult = vrt + seenViewed[key] = vrt + } + + unions := d.formatServiceUnions(facts) + + desc := facts.description + if desc == "" { + desc = fmt.Sprintf("Service is the %s service interface.", facts.name) + } + + varName := codegen.Goify(facts.name, false) + data := &Data{ + ServiceDeclaration: facts.names.declaration(serviceSymbolID{role: serviceInterfaceNameRole, service: facts.name}), + AutherDeclaration: facts.names[serviceSymbolID{role: serviceAutherNameRole, service: facts.name}].declaration, + APINameDeclaration: facts.names.declaration(serviceSymbolID{role: serviceAPINameRole, service: facts.name}), + APIVersionDeclaration: facts.names.declaration(serviceSymbolID{role: serviceAPIVersionNameRole, service: facts.name}), + ServiceNameDeclaration: facts.names.declaration(serviceSymbolID{role: serviceNameConstantRole, service: facts.name}), + MethodNamesDeclaration: facts.names.declaration(serviceSymbolID{role: serviceMethodNamesRole, service: facts.name}), + EndpointsDeclaration: facts.names.declaration(serviceSymbolID{role: serviceEndpointsNameRole, service: facts.name}), + NewEndpointsDeclaration: facts.names.declaration(serviceSymbolID{role: serviceNewEndpointsNameRole, service: facts.name}), + ClientDeclaration: facts.names.declaration(serviceSymbolID{role: serviceClientNameRole, service: facts.name}), + NewClientDeclaration: facts.names.declaration(serviceSymbolID{role: serviceNewClientNameRole, service: facts.name}), + ServerInterceptorsDeclaration: facts.names[serviceSymbolID{ + role: serviceServerInterceptorsNameRole, service: facts.name, + }].declaration, + ClientInterceptorsDeclaration: facts.names[serviceSymbolID{ + role: serviceClientInterceptorsNameRole, service: facts.name, + }].declaration, + ExampleStructDeclaration: facts.exampleStruct, + ExampleConstructorDeclaration: facts.exampleConstructor, + ExampleServerInterceptorsConstructorDeclaration: facts.exampleServerConstructor, + Name: facts.name, + Description: desc, + APIName: d.facts.apiName, + APIVersion: d.facts.apiVersion, + VarName: varName, + PathName: path.Base(facts.packagePath), + StructName: codegen.Goify(facts.name, true), + PkgName: pkgName, + ViewsPkg: viewsPkg, + Methods: methods, + Schemes: schemes, + ServerInterceptors: d.collectInterceptors(facts, facts.serverInterceptorFacts, methodDataByFacts, serviceResolver, true), + ClientInterceptors: d.collectInterceptors(facts, facts.clientInterceptorFacts, methodDataByFacts, serviceResolver, false), + Scope: scope, + ViewScope: viewScope, + errorTypes: errTypes, + errorInits: errorInits, + userTypes: types, + projectedTypes: projTypes, + viewedResultTypes: viewedRTs, + unions: unions, + viewUnions: viewUnions, + viewDerived: viewDerived, + } + return data, nil +} + +// collectInterceptors returns the set of interceptors defined on the given +// service including any interceptor defined on specific service methods or API. +func (d *ServicesData) collectInterceptors(service *serviceFacts, facts []*interceptorFacts, methods map[*methodFacts]*MethodData, resolver *declarationResolver, server bool) []*InterceptorData { + res := make([]*InterceptorData, 0, len(facts)) + for _, interceptor := range facts { + res = append(res, buildInterceptorData(service, interceptor, methods, resolver, server)) + } + return res +} + +// declarationContext configures transformations and validations to resolve +// every named service or view type through its planned package declaration. +func declarationContext(resolver codegen.Attributor, pointer bool) *codegen.AttributeContext { + return &codegen.AttributeContext{ + Pointer: pointer, + UseDefault: true, + Scope: resolver, + } +} + +// formatUserTypeFacts resolves the final names and definitions of types that +// collection already selected and assigned to generated packages. +func formatUserTypeFacts(facts []*userTypeFacts, aliases *importAliases) []*UserTypeData { + data := make([]*UserTypeData, len(facts)) + for index, facts := range facts { + linked := facts.layout.Link( + facts.declaration.PackagePath(), + retainedTypeQualifier(aliases, facts.declaration.PackagePath()), + ) + data[index] = &UserTypeData{ + Declaration: facts.declaration, + Name: facts.name, + VarName: facts.declaration.Name(), + Description: facts.description, + ErrorName: facts.errorName, + IsServiceError: facts.serviceError, + Def: linked.Def(), + Ref: facts.declaration.Ref(facts.userType), + Loc: facts.location, + Type: facts.userType, + } + } + return data +} + +// formatServiceUnions builds template data for the Goa OneOf declarations +// recorded during collection and adds one entry for each generated package. +func (d *ServicesData) formatServiceUnions(facts *serviceFacts) []*UnionTypeData { + unions := make([]*UnionTypeData, 0, len(facts.unions)) + for _, facts := range facts.unions { + union := buildRetainedUnionTypeData(facts, d.aliases) + facts.data = union + unions = append(unions, union) + } + sort.Slice(unions, func(i, j int) bool { + if unions[i].Name != unions[j].Name { + return unions[i].Name < unions[j].Name + } + var left, right string + if unions[i].Loc != nil { + left = unions[i].Loc.RelImportPath + } + if unions[j].Loc != nil { + right = unions[j].Loc.RelImportPath + } + return left < right + }) + return unions +} + +// formatViewUnions builds template data for the Goa OneOf declarations found +// while collecting result views. It does not walk the result types again. +func (d *ServicesData) formatViewUnions(facts *serviceFacts) []*UnionTypeData { + unions := make([]*UnionTypeData, len(facts.viewUnions)) + for index, union := range facts.viewUnions { + unions[index] = buildRetainedUnionTypeData(union, d.aliases) + } + sort.Slice(unions, func(i, j int) bool { + return unions[i].Name < unions[j].Name + }) + return unions +} + +// This helper builds template data for one Goa OneOf type from the branch names +// and Go types selected during planning. +func buildRetainedUnionTypeData(facts *unionFacts, aliases *importAliases) *UnionTypeData { + fields := make([]*UnionFieldData, len(facts.branches)) + for index, branch := range facts.branches { + fields[index] = &UnionFieldData{ + Name: branch.name, + KindConst: branch.declaration.KindConst(), + Constructor: branch.declaration.Constructor(), + KindDeclaration: branch.declaration.KindDeclaration(), + ConstructorDeclaration: branch.declaration.ConstructorDeclaration(), + FieldName: branch.fieldName, + FieldType: branch.layout.Link(facts.declaration.PackagePath(), retainedTypeQualifier(aliases, facts.declaration.PackagePath())).Ref(), + Nilable: branch.nilable, + EmitPrimitiveAlias: branch.emitPrimitiveAlias, + PrimitiveAliasType: branch.primitiveAliasType, + TypeTag: branch.name, + } + } + return &UnionTypeData{ + TypeDeclaration: facts.declaration.Declaration(), + KindDeclaration: facts.declaration.KindDeclaration(), + Name: facts.declaration.Name(), + KindName: facts.declaration.KindName(), + Fields: fields, + Loc: facts.location, + TypeKey: facts.typeKey, + ValueKey: facts.valueKey, + } +} + +// sortedNamedAttributes returns object fields sorted by attribute name. +// Union naming uses NameScope uniqueness, so callers that discover unions while +// traversing objects must use a deterministic field order to avoid oscillating +// generated identifiers across runs. +func sortedNamedAttributes(attrs []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { + if len(attrs) < 2 { + return attrs + } + sorted := slices.Clone(attrs) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Name < sorted[j].Name + }) + return sorted +} + +// primitiveAliasGoType resolves the native Go type for a primitive alias branch. +// It uses expr.IsPrimitive to enforce the type contract and then unwraps aliases. +func primitiveAliasGoType(dt expr.DataType) (string, bool) { + if !expr.IsPrimitive(dt) { + return "", false + } + for { + ut, ok := dt.(expr.UserType) + if !ok { + return codegen.GoNativeTypeName(dt), true + } + dt = ut.Attribute().Type + } +} + +// This helper builds constructor data for an error copied during collection +// without reading the design expression again. +func buildRetainedErrorInitData(facts *errorRenderFacts, resolver *declarationResolver, declaration *codegen.NameDeclaration) *ErrorInitData { + if facts.layout == nil { + panic(fmt.Sprintf("retained error %q has no Go type layout", facts.name)) + } + linked := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)) + name := "" + if facts.serviceType { + name = declaration.Name() + } + return &ErrorInitData{ + Declaration: declaration, + Name: name, + Description: facts.description, + ErrName: facts.name, + TypeName: linked.Name(), + TypeRef: linked.Ref(), + Temporary: facts.temporary, + Timeout: facts.timeout, + Fault: facts.fault, + } +} + +// This helper returns the Go import name chosen for a type recorded during +// planning. +func retainedTypeQualifier(aliases *importAliases, outputPackage string) codegen.GoTypeQualifier { + return func(importPath string) string { + return aliases.name(outputPackage, importPath) + } +} diff --git a/codegen/service/service_name_collision_contract_test.go b/codegen/service/service_name_collision_contract_test.go new file mode 100644 index 0000000000..792bde69ac --- /dev/null +++ b/codegen/service/service_name_collision_contract_test.go @@ -0,0 +1,154 @@ +// This file verifies every core service package symbol participates in the +// single Go package namespace with authored types and other generated symbols. +package service + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type serviceCollisionResult struct { + authored string + subject string + competitor string +} + +// TestEveryServiceNameRoleSharesOnePackageNamespace catches a service symbol +// family that bypasses exact authored types or uses discovery order to choose +// collision suffixes. +func TestEveryServiceNameRoleSharesOnePackageNamespace(t *testing.T) { + roles := []serviceNameRole{ + serviceInterfaceNameRole, + serviceAutherNameRole, + serviceAPINameRole, + serviceAPIVersionNameRole, + serviceNameConstantRole, + serviceMethodNamesRole, + serviceServerStreamNameRole, + serviceClientStreamNameRole, + serviceErrorConstructorNameRole, + serviceViewConstructorNameRole, + servicePrivateProjectionConstructorNameRole, + serviceValidatorNameRole, + serviceViewMapNameRole, + serviceEndpointsNameRole, + serviceNewEndpointsNameRole, + serviceClientNameRole, + serviceNewClientNameRole, + serviceMethodEndpointNameRole, + serviceEndpointInputNameRole, + serviceRequestNameRole, + serviceResponseNameRole, + serviceServerInterceptorsNameRole, + serviceClientInterceptorsNameRole, + serviceInterceptorInfoNameRole, + serviceInterceptorPayloadNameRole, + serviceInterceptorResultNameRole, + serviceInterceptorStreamingPayloadNameRole, + serviceInterceptorStreamingResultNameRole, + serviceInterceptorPayloadAccessNameRole, + serviceInterceptorResultAccessNameRole, + serviceInterceptorStreamingPayloadAccessNameRole, + serviceInterceptorStreamingResultAccessNameRole, + serviceInterceptorMethodInfoNameRole, + serviceInterceptorServerUnaryInfoNameRole, + serviceInterceptorClientUnaryInfoNameRole, + serviceInterceptorStreamingSendInfoNameRole, + serviceInterceptorStreamingRecvInfoNameRole, + serviceServerEndpointWrapperNameRole, + serviceClientEndpointWrapperNameRole, + serviceServerInterceptorWrapperNameRole, + serviceClientInterceptorWrapperNameRole, + serviceServerStreamWrapperNameRole, + serviceClientStreamWrapperNameRole, + serviceTransformHelperNameRole, + serviceExampleStructNameRole, + serviceExampleConstructorNameRole, + serviceExampleServerInterceptorsStructNameRole, + serviceExampleServerInterceptorsConstructorNameRole, + serviceExampleClientInterceptorsStructNameRole, + serviceExampleClientInterceptorsConstructorNameRole, + } + + for _, role := range roles { + t.Run(fmt.Sprintf("role-%d", role), func(t *testing.T) { + forward := serviceCollisionNames(t, role, false) + reverse := serviceCollisionNames(t, role, true) + wantGenerated := []string{"Symbol2", "Symbol3"} + if role.visibility() == codegen.UnexportedName { + wantGenerated = []string{"symbol", "symbol2"} + } + + require.Equal(t, forward, reverse) + require.Equal(t, "Symbol", forward.authored) + require.ElementsMatch(t, wantGenerated, []string{ + forward.subject, + forward.competitor, + }) + }) + } +} + +// serviceCollisionNames declares one role and an unrelated generated function +// in the requested order, then returns their frozen names. +func serviceCollisionNames(t *testing.T, role serviceNameRole, reverse bool) serviceCollisionResult { + t.Helper() + generation := mustTestGeneration(t, "generated.local/gen", nil) + generatedPackage := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + authored := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Symbol", + UID: fmt.Sprintf("authored-symbol-%d", role), + } + authoredDeclaration, err := generatedPackage.DeclareUserType(authored) + require.NoError(t, err) + + competitorRole := serviceErrorConstructorNameRole + if role.visibility() == codegen.UnexportedName { + competitorRole = servicePrivateProjectionConstructorNameRole + if role == competitorRole { + competitorRole = serviceTransformHelperNameRole + } + } else if role == competitorRole { + competitorRole = serviceValidatorNameRole + } + subjectID := serviceSymbolID{ + role: role, + service: "calc", + subject: "subject", + } + competitorID := serviceSymbolID{ + role: competitorRole, + service: "calc", + subject: "competitor", + } + names := make(serviceNames) + declare := func(id serviceSymbolID) *codegen.NameDeclaration { + declaration, err := names.declare(generatedPackage, id, "Symbol") + require.NoError(t, err) + repeated, err := names.declare(generatedPackage, id, "Symbol") + require.NoError(t, err) + require.Same(t, declaration, repeated) + return declaration + } + var subject, competitor *codegen.NameDeclaration + if reverse { + competitor = declare(competitorID) + subject = declare(subjectID) + } else { + subject = declare(subjectID) + competitor = declare(competitorID) + } + require.NoError(t, generation.Freeze()) + + return serviceCollisionResult{ + authored: authoredDeclaration.Name(), + subject: subject.Name(), + competitor: competitor.Name(), + } +} diff --git a/codegen/service/service_names.go b/codegen/service/service_names.go new file mode 100644 index 0000000000..46f2b4c9c8 --- /dev/null +++ b/codegen/service/service_names.go @@ -0,0 +1,344 @@ +// This file records every package-level Go declaration written by the service +// and views generators. Each definition and reference reads its name from the +// same NameDeclaration. +package service + +import ( + "cmp" + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // serviceNameRole identifies what one package-level declaration does in the + // generated service or views package. + serviceNameRole uint8 + + // serviceNameOrder contains design names and fixed categories that order two + // declarations requesting the same Go name. It does not depend on the order + // in which generators find them. + serviceNameOrder struct { + role serviceNameRole + service string + api string + method string + subject string + view string + source string + target string + side string + occurrence int + required bool + } + + // serviceSymbolID identifies one package declaration without using the Go + // name that will be chosen later. Source and target distinguish conversion + // helpers; subject and view distinguish constructors and validators. + serviceSymbolID serviceNameOrder + + // serviceName stores the requested Go name and the NameDeclaration created + // for it. Repeated collection must return that same declaration. + serviceName struct { + preferred string + base *codegen.NameDeclaration + prefix string + suffix string + declaration *codegen.NameDeclaration + } + + // serviceNames maps each service declaration purpose to the NameDeclaration + // stored in its generated Go package. + serviceNames map[serviceSymbolID]serviceName +) + +const ( + serviceInterfaceNameRole serviceNameRole = iota + 1 + serviceAutherNameRole + serviceAPINameRole + serviceAPIVersionNameRole + serviceNameConstantRole + serviceMethodNamesRole + serviceServerStreamNameRole + serviceClientStreamNameRole + serviceErrorConstructorNameRole + serviceViewConstructorNameRole + servicePrivateProjectionConstructorNameRole + serviceValidatorNameRole + serviceViewMapNameRole + serviceEndpointsNameRole + serviceNewEndpointsNameRole + serviceClientNameRole + serviceNewClientNameRole + serviceMethodEndpointNameRole + serviceEndpointInputNameRole + serviceRequestNameRole + serviceResponseNameRole + serviceServerInterceptorsNameRole + serviceClientInterceptorsNameRole + serviceInterceptorInfoNameRole + serviceInterceptorPayloadNameRole + serviceInterceptorResultNameRole + serviceInterceptorStreamingPayloadNameRole + serviceInterceptorStreamingResultNameRole + serviceInterceptorPayloadAccessNameRole + serviceInterceptorResultAccessNameRole + serviceInterceptorStreamingPayloadAccessNameRole + serviceInterceptorStreamingResultAccessNameRole + serviceInterceptorMethodInfoNameRole + serviceInterceptorServerUnaryInfoNameRole + serviceInterceptorClientUnaryInfoNameRole + serviceInterceptorStreamingSendInfoNameRole + serviceInterceptorStreamingRecvInfoNameRole + serviceServerEndpointWrapperNameRole + serviceClientEndpointWrapperNameRole + serviceServerInterceptorWrapperNameRole + serviceClientInterceptorWrapperNameRole + serviceServerStreamWrapperNameRole + serviceClientStreamWrapperNameRole + serviceTransformHelperNameRole + serviceExampleStructNameRole + serviceExampleConstructorNameRole + serviceExampleServerInterceptorsStructNameRole + serviceExampleServerInterceptorsConstructorNameRole + serviceExampleClientInterceptorsStructNameRole + serviceExampleClientInterceptorsConstructorNameRole +) + +// ComparePackageName orders service declarations by their purpose and design +// names, so discovery order cannot change generated Go names. +func (o serviceNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(serviceNameOrder) + if compared := cmp.Compare(o.role, right.role); compared != 0 { + return compared + } + if compared := cmp.Compare(o.service, right.service); compared != 0 { + return compared + } + if compared := cmp.Compare(o.api, right.api); compared != 0 { + return compared + } + if compared := cmp.Compare(o.method, right.method); compared != 0 { + return compared + } + if compared := cmp.Compare(o.subject, right.subject); compared != 0 { + return compared + } + if compared := cmp.Compare(o.view, right.view); compared != 0 { + return compared + } + if compared := cmp.Compare(o.source, right.source); compared != 0 { + return compared + } + if compared := cmp.Compare(o.target, right.target); compared != 0 { + return compared + } + if compared := cmp.Compare(o.side, right.side); compared != 0 { + return compared + } + if compared := cmp.Compare(o.occurrence, right.occurrence); compared != 0 { + return compared + } + if o.required == right.required { + return 0 + } + if !o.required { + return -1 + } + return 1 +} + +// kind returns whether this role writes a Go type, function, constant, or +// variable. An unknown role means the generator omitted a supported case. +func (r serviceNameRole) kind() codegen.PackageNameKind { + switch r { + case serviceAPINameRole, serviceAPIVersionNameRole, serviceNameConstantRole: + return codegen.NameConstant + case serviceMethodNamesRole, serviceViewMapNameRole: + return codegen.NameVariable + case serviceErrorConstructorNameRole, + serviceViewConstructorNameRole, + servicePrivateProjectionConstructorNameRole, + serviceValidatorNameRole, + serviceNewEndpointsNameRole, + serviceNewClientNameRole, + serviceMethodEndpointNameRole, + serviceServerEndpointWrapperNameRole, + serviceClientEndpointWrapperNameRole, + serviceServerInterceptorWrapperNameRole, + serviceClientInterceptorWrapperNameRole, + serviceTransformHelperNameRole, + serviceExampleConstructorNameRole, + serviceExampleServerInterceptorsConstructorNameRole, + serviceExampleClientInterceptorsConstructorNameRole: + return codegen.NameFunction + case serviceInterfaceNameRole, + serviceAutherNameRole, + serviceServerStreamNameRole, + serviceClientStreamNameRole, + serviceEndpointsNameRole, + serviceClientNameRole, + serviceEndpointInputNameRole, + serviceRequestNameRole, + serviceResponseNameRole, + serviceServerInterceptorsNameRole, + serviceClientInterceptorsNameRole, + serviceInterceptorInfoNameRole, + serviceInterceptorPayloadNameRole, + serviceInterceptorResultNameRole, + serviceInterceptorStreamingPayloadNameRole, + serviceInterceptorStreamingResultNameRole, + serviceInterceptorPayloadAccessNameRole, + serviceInterceptorResultAccessNameRole, + serviceInterceptorStreamingPayloadAccessNameRole, + serviceInterceptorStreamingResultAccessNameRole, + serviceInterceptorMethodInfoNameRole, + serviceInterceptorServerUnaryInfoNameRole, + serviceInterceptorClientUnaryInfoNameRole, + serviceInterceptorStreamingSendInfoNameRole, + serviceInterceptorStreamingRecvInfoNameRole, + serviceServerStreamWrapperNameRole, + serviceClientStreamWrapperNameRole, + serviceExampleStructNameRole, + serviceExampleServerInterceptorsStructNameRole, + serviceExampleClientInterceptorsStructNameRole: + return codegen.NameType + default: + panic(fmt.Sprintf("unknown service package name role %d", r)) + } +} + +// visibility reports whether callers outside the generated package can use the +// declaration. +func (r serviceNameRole) visibility() codegen.PackageNameVisibility { + switch r { + case servicePrivateProjectionConstructorNameRole, + serviceInterceptorPayloadAccessNameRole, + serviceInterceptorResultAccessNameRole, + serviceInterceptorStreamingPayloadAccessNameRole, + serviceInterceptorStreamingResultAccessNameRole, + serviceInterceptorMethodInfoNameRole, + serviceInterceptorServerUnaryInfoNameRole, + serviceInterceptorClientUnaryInfoNameRole, + serviceInterceptorStreamingSendInfoNameRole, + serviceInterceptorStreamingRecvInfoNameRole, + serviceServerInterceptorWrapperNameRole, + serviceClientInterceptorWrapperNameRole, + serviceServerStreamWrapperNameRole, + serviceClientStreamWrapperNameRole, + serviceTransformHelperNameRole, + serviceExampleStructNameRole: + return codegen.UnexportedName + default: + return codegen.ExportedName + } +} + +// declare submits one requested Go name to pkg. Repeated calls for the same id +// return the same NameDeclaration and reject a different requested name. +func (n serviceNames) declare(pkg *codegen.GeneratedPackage, id serviceSymbolID, preferred string) (*codegen.NameDeclaration, error) { + return n.declareForAPI(pkg, id, preferred, "") +} + +// declareForAPI submits one generated name using the API to distinguish two +// roots that intentionally contribute to the same service package. +func (n serviceNames) declareForAPI(pkg *codegen.GeneratedPackage, id serviceSymbolID, preferred, api string) (*codegen.NameDeclaration, error) { + if existing, ok := n[id]; ok { + if existing.base != nil || existing.preferred != preferred { + return nil, fmt.Errorf( + "service symbol role %d cannot declare both %q and %q", + id.role, + existing.preferred, + preferred, + ) + } + if err := pkg.DeclareName(existing.declaration); err != nil { + return nil, err + } + return existing.declaration, nil + } + + order := serviceNameOrder(id) + order.api = api + declaration := codegen.NewPreferredName( + id.role.kind(), + preferred, + id.role.visibility(), + order, + ) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + n[id] = serviceName{preferred: preferred, declaration: declaration} + return declaration, nil +} + +// declareDependent submits a declaration whose Go name is built by adding +// prefix and suffix to base's final name. Repeated calls for the same id must +// use the same base, prefix, and suffix. +func (n serviceNames) declareDependent(pkg *codegen.GeneratedPackage, id serviceSymbolID, base *codegen.NameDeclaration, prefix, suffix string) (*codegen.NameDeclaration, error) { + return n.declareDependentForAPI(pkg, id, base, prefix, suffix, "") +} + +// declareDependentForAPI submits one dependent generated name using the API to +// distinguish two roots that intentionally contribute to the same package. +func (n serviceNames) declareDependentForAPI(pkg *codegen.GeneratedPackage, id serviceSymbolID, base *codegen.NameDeclaration, prefix, suffix, api string) (*codegen.NameDeclaration, error) { + if existing, ok := n[id]; ok { + if existing.base != base || existing.prefix != prefix || existing.suffix != suffix { + return nil, fmt.Errorf("service symbol role %d cannot change its dependent declaration family", id.role) + } + if err := pkg.DeclareName(existing.declaration); err != nil { + return nil, err + } + return existing.declaration, nil + } + + order := serviceNameOrder(id) + order.api = api + declaration, err := pkg.DeclareDependentName( + id.role.kind(), + base, + prefix, + suffix, + order, + ) + if err != nil { + return nil, err + } + n[id] = serviceName{ + base: base, + prefix: prefix, + suffix: suffix, + declaration: declaration, + } + return declaration, nil +} + +// declaration returns the NameDeclaration previously stored for id. It panics +// when name collection did not submit that id. +func (n serviceNames) declaration(id serviceSymbolID) *codegen.NameDeclaration { + name, ok := n[id] + if !ok { + panic(fmt.Sprintf("service symbol role %d was not declared", id.role)) + } + return name.declaration +} + +// transformDataTypeName returns the design name and ID used to order one side +// of a generated conversion helper. +func transformDataTypeName(dataType expr.DataType) (string, string) { + if userType, ok := dataType.(expr.UserType); ok { + return userType.Name(), userType.ID() + } + return dataType.Name(), "" +} + +// canonicalValidatorView returns an empty string for the default result view +// so it matches validation calls that omit a view name. +func canonicalValidatorView(view string) string { + if view == expr.DefaultView { + return "" + } + return view +} diff --git a/codegen/service/service_names_test.go b/codegen/service/service_names_test.go new file mode 100644 index 0000000000..f10ff39310 --- /dev/null +++ b/codegen/service/service_names_test.go @@ -0,0 +1,191 @@ +// This file verifies the typed package-level declaration identities used by +// retained service plans before any generated source is rendered. +package service + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// TestServiceNamesAreIndependentOfDiscoveryOrder verifies that stable semantic +// identities, rather than traversal order, decide collision suffixes. +func TestServiceNamesAreIndependentOfDiscoveryOrder(t *testing.T) { + ids := []serviceSymbolID{ + {role: serviceValidatorNameRole, service: "calc", subject: "Result"}, + {role: serviceErrorConstructorNameRole, service: "calc", subject: "Result"}, + {role: serviceMethodEndpointNameRole, service: "calc", method: "add"}, + } + + generate := func(order []int) map[serviceSymbolID]string { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + names := make(serviceNames) + for _, index := range order { + _, err := names.declare(pkg, ids[index], "Build") + require.NoError(t, err) + } + require.NoError(t, generation.Freeze()) + result := make(map[serviceSymbolID]string, len(ids)) + for _, id := range ids { + result[id] = names.declaration(id).Name() + } + return result + } + + require.Equal(t, generate([]int{0, 1, 2}), generate([]int{2, 0, 1})) +} + +// TestServiceNamesShareTheAuthoredPackageNamespace verifies that generated +// functions collide with exact authored types and with one another in the one +// namespace enforced by the Go compiler. +func TestServiceNamesShareTheAuthoredPackageNamespace(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + authored := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "ValidateResult", + UID: "authored-validate-result", + } + authoredDeclaration, err := pkg.DeclareUserType(authored) + require.NoError(t, err) + + names := make(serviceNames) + validator, err := names.declare(pkg, serviceSymbolID{ + role: serviceValidatorNameRole, + service: "calc", + subject: "Result", + }, "ValidateResult") + require.NoError(t, err) + constructor, err := names.declare(pkg, serviceSymbolID{ + role: serviceErrorConstructorNameRole, + service: "calc", + subject: "Result", + }, "ValidateResult") + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "ValidateResult", authoredDeclaration.Name()) + require.Equal(t, "ValidateResult2", constructor.Name()) + require.Equal(t, "ValidateResult3", validator.Name()) +} + +// TestServiceNamesOwnOneCanonicalDeclaration verifies that rebuilding an exact +// semantic identity returns the original record and rejects changed spelling +// or ownership. +func TestServiceNamesOwnOneCanonicalDeclaration(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + servicePackage := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + viewsPackage := mustClaimTestPackage(t, generation, "generated.local/gen/calc/views") + names := make(serviceNames) + id := serviceSymbolID{role: serviceViewConstructorNameRole, service: "calc", subject: "Result"} + + first, err := names.declare(servicePackage, id, "NewViewedResult") + require.NoError(t, err) + second, err := names.declare(servicePackage, id, "NewViewedResult") + require.NoError(t, err) + require.Same(t, first, second) + + _, err = names.declare(servicePackage, id, "NewResultView") + require.ErrorContains(t, err, "cannot declare both") + _, err = names.declare(viewsPackage, id, "NewViewedResult") + require.ErrorContains(t, err, "already belongs") +} + +// TestServiceNamesDeriveCompanionsFromFrozenTypes verifies validators follow +// the exact projected type declaration when that type receives a suffix. +func TestServiceNamesDeriveCompanionsFromFrozenTypes(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/calc/views") + require.NoError(t, pkg.DeclareName(codegen.NewExactName(codegen.NameType, "Result"))) + base := codegen.NewPreferredName(codegen.NameType, "Result", codegen.ExportedName, serviceNameOrder{ + role: serviceInterfaceNameRole, + service: "calc", + subject: "result", + }) + require.NoError(t, pkg.DeclareName(base)) + names := make(serviceNames) + validator, err := names.declareDependent(pkg, serviceSymbolID{ + role: serviceValidatorNameRole, + service: "calc", + subject: "result", + }, base, "Validate", "") + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "Result2", base.Name()) + require.Equal(t, "ValidateResult2", validator.Name()) +} + +// TestServiceNameRolesOwnDeclarationKinds verifies that the closed service +// symbol family, not an individual caller, selects each Go declaration kind. +func TestServiceNameRolesOwnDeclarationKinds(t *testing.T) { + tests := []struct { + role serviceNameRole + kind codegen.PackageNameKind + }{ + {serviceInterfaceNameRole, codegen.NameType}, + {serviceAutherNameRole, codegen.NameType}, + {serviceAPINameRole, codegen.NameConstant}, + {serviceAPIVersionNameRole, codegen.NameConstant}, + {serviceNameConstantRole, codegen.NameConstant}, + {serviceMethodNamesRole, codegen.NameVariable}, + {serviceServerStreamNameRole, codegen.NameType}, + {serviceClientStreamNameRole, codegen.NameType}, + {serviceErrorConstructorNameRole, codegen.NameFunction}, + {serviceViewConstructorNameRole, codegen.NameFunction}, + {servicePrivateProjectionConstructorNameRole, codegen.NameFunction}, + {serviceValidatorNameRole, codegen.NameFunction}, + {serviceViewMapNameRole, codegen.NameVariable}, + {serviceEndpointsNameRole, codegen.NameType}, + {serviceNewEndpointsNameRole, codegen.NameFunction}, + {serviceClientNameRole, codegen.NameType}, + {serviceNewClientNameRole, codegen.NameFunction}, + {serviceMethodEndpointNameRole, codegen.NameFunction}, + {serviceEndpointInputNameRole, codegen.NameType}, + {serviceRequestNameRole, codegen.NameType}, + {serviceResponseNameRole, codegen.NameType}, + {serviceServerInterceptorsNameRole, codegen.NameType}, + {serviceClientInterceptorsNameRole, codegen.NameType}, + {serviceInterceptorInfoNameRole, codegen.NameType}, + {serviceInterceptorPayloadNameRole, codegen.NameType}, + {serviceInterceptorResultNameRole, codegen.NameType}, + {serviceInterceptorStreamingPayloadNameRole, codegen.NameType}, + {serviceInterceptorStreamingResultNameRole, codegen.NameType}, + {serviceInterceptorPayloadAccessNameRole, codegen.NameType}, + {serviceInterceptorResultAccessNameRole, codegen.NameType}, + {serviceInterceptorStreamingPayloadAccessNameRole, codegen.NameType}, + {serviceInterceptorStreamingResultAccessNameRole, codegen.NameType}, + {serviceInterceptorMethodInfoNameRole, codegen.NameType}, + {serviceInterceptorServerUnaryInfoNameRole, codegen.NameType}, + {serviceInterceptorClientUnaryInfoNameRole, codegen.NameType}, + {serviceInterceptorStreamingSendInfoNameRole, codegen.NameType}, + {serviceInterceptorStreamingRecvInfoNameRole, codegen.NameType}, + {serviceServerEndpointWrapperNameRole, codegen.NameFunction}, + {serviceClientEndpointWrapperNameRole, codegen.NameFunction}, + {serviceServerInterceptorWrapperNameRole, codegen.NameFunction}, + {serviceClientInterceptorWrapperNameRole, codegen.NameFunction}, + {serviceServerStreamWrapperNameRole, codegen.NameType}, + {serviceClientStreamWrapperNameRole, codegen.NameType}, + {serviceTransformHelperNameRole, codegen.NameFunction}, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("role-%d", test.role), func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + names := make(serviceNames) + declaration, err := names.declare(pkg, serviceSymbolID{ + role: test.role, + service: "calc", + subject: "result", + }, "Symbol") + require.NoError(t, err) + require.Equal(t, test.kind, declaration.Kind()) + }) + } +} diff --git a/codegen/service/service_package_path.go b/codegen/service/service_package_path.go new file mode 100644 index 0000000000..ed3487fc10 --- /dev/null +++ b/codegen/service/service_package_path.go @@ -0,0 +1,96 @@ +// This file assigns every generated service package path once for a complete +// planning run. Later planning phases read the retained path instead of +// rebuilding it from a service name. +package service + +import ( + "fmt" + "path" + "sort" + "strconv" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // serviceDesignID identifies declarations contributed by one service in one + // API. Two roots with the same value cannot be ordered without another design + // fact, so planning rejects them. + serviceDesignID struct { + api string + service string + } +) + +// allocateServicePackagePaths returns one generated package path for every +// exact service name in inputs. Exact names share a path across APIs. Different +// names that produce the same normal path receive numeric suffixes ordered by +// their authored names. +func allocateServicePackagePaths(genpkg string, inputs []PlanInput) (map[string]string, error) { + names := make(map[string]struct{}) + designs := make(map[serviceDesignID]*expr.RootExpr) + for _, input := range inputs { + for _, service := range input.Root.Services { + identity := serviceDesignID{api: input.Root.API.Name, service: service.Name} + if root := designs[identity]; root != nil && root != input.Root { + return nil, fmt.Errorf( + "service %q in API %q is planned by more than one root", + service.Name, + input.Root.API.Name, + ) + } + designs[identity] = input.Root + names[service.Name] = struct{}{} + } + } + + orderedNames := make([]string, 0, len(names)) + groups := make(map[string][]string) + reserved := make(map[string]struct{}) + for name := range names { + orderedNames = append(orderedNames, name) + base := servicePackageName(name) + groups[base] = append(groups[base], name) + reserved[base] = struct{}{} + } + sort.Strings(orderedNames) + for _, names := range groups { + sort.Strings(names) + } + + assignedNames := make(map[string]string, len(names)) + used := make(map[string]struct{}, len(names)) + for _, name := range orderedNames { + base := servicePackageName(name) + if groups[base][0] == name { + assignedNames[name] = base + used[base] = struct{}{} + continue + } + for suffix := 2; ; suffix++ { + candidate := base + strconv.Itoa(suffix) + if _, exists := reserved[candidate]; exists { + continue + } + if _, exists := used[candidate]; exists { + continue + } + assignedNames[name] = candidate + used[candidate] = struct{}{} + break + } + } + + paths := make(map[string]string, len(assignedNames)) + for name, packageName := range assignedNames { + paths[name] = path.Join(genpkg, packageName) + } + return paths, nil +} + +// servicePackageName returns the package directory naturally produced by one +// authored service name before collisions are resolved. +func servicePackageName(name string) string { + return codegen.SnakeCase(codegen.Goify(name, false)) +} diff --git a/codegen/service/service_package_path_test.go b/codegen/service/service_package_path_test.go new file mode 100644 index 0000000000..9cdb0454f0 --- /dev/null +++ b/codegen/service/service_package_path_test.go @@ -0,0 +1,154 @@ +// This file verifies one complete service planning run assigns generated +// package paths before any service declarations are collected. +package service + +import ( + "path" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // servicePathTestInput names one API and service included in a complete + // planning run. + servicePathTestInput struct { + api string + service string + } +) + +// TestNewPlansAssignsStableServicePackagePaths verifies distinct service names +// that have the same normal path receive stable unique paths. A service whose +// name naturally contains a numeric suffix keeps that path. +func TestNewPlansAssignsStableServicePackagePaths(t *testing.T) { + forward := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "dash api", service: "read-value"}, + {api: "underscore api", service: "read_value"}, + {api: "numbered api", service: "read_value2"}, + }) + reverse := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "numbered api", service: "read_value2"}, + {api: "underscore api", service: "read_value"}, + {api: "dash api", service: "read-value"}, + }) + + require.Equal(t, forward, reverse) + require.Equal(t, map[string]string{ + "read-value": "generated.local/gen/read_value", + "read_value": "generated.local/gen/read_value3", + "read_value2": "generated.local/gen/read_value2", + }, forward) +} + +// TestNewPlansSharesServicePackagePathAcrossRoots verifies the same exact +// service name uses one generated package when two APIs contribute to it. +func TestNewPlansSharesServicePackagePathAcrossRoots(t *testing.T) { + forward := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "first api", service: "Shared"}, + {api: "second api", service: "Shared"}, + }) + reverse := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "second api", service: "Shared"}, + {api: "first api", service: "Shared"}, + }) + + require.Equal(t, forward, reverse) + require.Equal(t, map[string]string{ + "first api/Shared": "generated.local/gen/shared", + "second api/Shared": "generated.local/gen/shared", + }, forward) +} + +// TestNewPlansRejectsRepeatedAPIService verifies two roots cannot contribute +// indistinguishable declarations for the same API and service. +func TestNewPlansRejectsRepeatedAPIService(t *testing.T) { + first := servicePathTestRoot(t, "same api", "Shared") + second := servicePathTestRoot(t, "same api", "Shared") + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{first, second}) + + _, err := NewPlans( + generation, + PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + + require.EqualError(t, err, `service "Shared" in API "same api" is planned by more than one root`) +} + +// TestNewPlansKeepsNoncollidingServicePackagePaths verifies ordinary service +// names keep the paths generated by earlier Goa versions. +func TestNewPlansKeepsNoncollidingServicePackagePaths(t *testing.T) { + paths := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "storage api", service: "Storage"}, + {api: "audit api", service: "AuditLog"}, + }) + + require.Equal(t, map[string]string{ + "Storage": "generated.local/gen/storage", + "AuditLog": "generated.local/gen/audit_log", + }, paths) +} + +// plannedServicePackagePaths builds and freezes one complete planning run, then +// returns the retained package path for every input service. +func plannedServicePackagePaths(t *testing.T, inputs []servicePathTestInput) map[string]string { + t.Helper() + roots := make([]*expr.RootExpr, len(inputs)) + evaluated := make([]eval.Root, len(inputs)) + planInputs := make([]PlanInput, len(inputs)) + for index, input := range inputs { + root := servicePathTestRoot(t, input.api, input.service) + roots[index] = root + evaluated[index] = root + planInputs[index] = PlanInput{ + Root: root, + Examples: expr.NewExampleGenerator(root.API.RandomizerFactory), + } + } + generation := mustTestGeneration(t, "generated.local/gen", evaluated) + plans, err := NewPlans(generation, planInputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + paths := make(map[string]string, len(inputs)) + sharedNames := make(map[string]int) + for _, input := range inputs { + sharedNames[input.service]++ + } + for index, plan := range plans { + service := roots[index].Service(inputs[index].service) + serviceImport, _, err := plan.ServicePackageImports(service) + require.NoError(t, err) + require.NoError(t, plan.Link()) + require.Equal(t, path.Base(serviceImport.Path), plan.Services().Get(service.Name).PathName) + key := inputs[index].service + if sharedNames[key] > 1 { + key = inputs[index].api + "/" + key + } + paths[key] = serviceImport.Path + } + return paths +} + +// servicePathTestRoot creates one evaluated design with a single service and +// gives its API the identity used to order shared package declarations. +func servicePathTestRoot(t *testing.T, api, service string) *expr.RootExpr { + t.Helper() + root := codegen.RunDSL(t, func() { + dsl.Service(service, func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + root.API.Name = api + return root +} diff --git a/codegen/service/service_plan_compile_contract_test.go b/codegen/service/service_plan_compile_contract_test.go new file mode 100644 index 0000000000..2a279ba5e4 --- /dev/null +++ b/codegen/service/service_plan_compile_contract_test.go @@ -0,0 +1,214 @@ +// This file compiles generated service, views, and starter implementation +// packages for nested validation collisions shaped like AURA tool contracts. +package service + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service/testdata" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestServicePackageNameUsesClaimedImportPath verifies service package names +// remain lowercase after the final generated import path is claimed. +func TestServicePackageNameUsesClaimedImportPath(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("api_key_service", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.OneOf("credential", func() { + dsl.Attribute("api_key", dsl.String) + dsl.Attribute("token", dsl.String) + }) + dsl.Required("credential") + }) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + require.Equal(t, "apikeyservice", plan.Services().Get("api_key_service").PkgName) + + files, err := Files(plan) + require.NoError(t, err) + directory := t.TempDir() + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + source, err := os.ReadFile(filepath.Join(directory, codegen.Gendir, "api_key_service", "service.go")) + require.NoError(t, err) + require.Contains(t, string(source), "package apikeyservice") + unionSource, err := os.ReadFile(filepath.Join(directory, codegen.Gendir, "api_key_service", "unions.go")) + require.NoError(t, err) + require.Contains(t, string(unionSource), "package apikeyservice") +} + +// TestRepeatedInlineMethodErrorsCompile verifies equivalent method errors use +// one generated public error declaration. +func TestRepeatedInlineMethodErrorsCompile(t *testing.T) { + root := codegen.RunDSL(t, testdata.RepeatedInlineErrorsDSL) + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) + + rendered := renderedServiceFiles(t, files) + serviceSource := string(rendered[filepath.Join(codegen.Gendir, "secured", "service.go")]) + require.Equal(t, 1, strings.Count(serviceSource, "type InvalidScopes string")) +} + +// TestNestedViewValidatorCollisionCompiles catches a parent validator that +// reconstructs its child's preferred name after the child function was +// suffixed by another projected declaration in the views package. +func TestNestedViewValidatorCollisionCompiles(t *testing.T) { + root := codegen.RunDSL(t, func() { + child := dsl.ResultType("application/vnd.child", func() { + dsl.TypeName("Child") + dsl.Attribute("name", dsl.String, func() { + dsl.MinLength(1) + }) + dsl.Required("name") + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + collision := dsl.Type("ValidateChild", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + parent := dsl.ResultType("application/vnd.parent", func() { + dsl.TypeName("Parent") + dsl.Attribute("child", child) + dsl.Attribute("children", dsl.CollectionOf(child)) + dsl.Attribute("validator_name_collision", collision) + dsl.Required("child", "children", "validator_name_collision") + dsl.View("default", func() { + dsl.Attribute("child") + dsl.Attribute("children") + dsl.Attribute("validator_name_collision") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(parent) + }) + }) + }) + + plan := retainedServicePlanForPackage(t, root) + data := plan.Services().Get("Values") + var childValidation, parentValidation *ValidateData + for _, projected := range data.projectedTypes { + for _, validation := range projected.Validations { + switch projected.Name { + case "ChildView": + childValidation = validation + case "ParentView": + parentValidation = validation + } + } + } + require.NotNil(t, childValidation) + require.NotNil(t, parentValidation) + require.NotEmpty(t, parentValidation.Calls) + require.Same(t, childValidation.Declaration, parentValidation.Calls[0].Declaration) + require.Equal(t, "ValidateChildView2", childValidation.Declaration.Name()) + + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, files) +} + +// TestMixedResultStarterCompiles checks that a fresh starter implements the +// service method that returns one normal result and may also send stream values. +func TestMixedResultStarterCompiles(t *testing.T) { + cases := []struct { + Name string + DSL func() + }{ + {"result and stream", testdata.MixedResultsEndpointDSL}, + {"result view and stream", testdata.MixedResultsWithViewsEndpointDSL}, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + root := codegen.RunDSL(t, c.DSL) + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, files) + }) + } +} + +// retainedServicePlanForPackage builds and links service generation data using +// the import path shared by these compilation tests. +func retainedServicePlanForPackage(t *testing.T, root *expr.RootExpr) *Plan { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + return plan +} + +// compileGeneratedServiceFiles renders files into a temporary module and runs +// the Go compiler against every generated package. +func compileGeneratedServiceFiles(t *testing.T, files []*codegen.File) { + compileGeneratedServiceFilesWith(t, files, nil) +} + +// compileGeneratedServiceFilesWith renders files and additional test source +// into a temporary module, then runs every generated package test. +func compileGeneratedServiceFilesWith(t *testing.T, files []*codegen.File, additional map[string]string) { + t.Helper() + directory := t.TempDir() + goaRoot := serviceModuleDirectory(t, "goa.design/goa/v3") + module := "module generated.local\n\ngo 1.24\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaRoot) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + for path, source := range additional { + fullPath := filepath.Join(directory, path) + require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o700)) + require.NoError(t, os.WriteFile(fullPath, []byte(source), 0o600)) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "compile generated packages:\n%s", output) +} + +// serviceModuleDirectory resolves the local checkout for a module used by a +// temporary generated module. +func serviceModuleDirectory(t *testing.T, module string) string { + t.Helper() + command := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", module) + output, err := command.CombinedOutput() + require.NoError(t, err, "resolve module %s:\n%s", module, output) + directory := strings.TrimSpace(string(output)) + require.NotEmpty(t, directory) + return directory +} diff --git a/codegen/service/service_plan_render_contract_test.go b/codegen/service/service_plan_render_contract_test.go new file mode 100644 index 0000000000..c38ccd9d1c --- /dev/null +++ b/codegen/service/service_plan_render_contract_test.go @@ -0,0 +1,223 @@ +// This file verifies retained service plans aggregate shared packages +// deterministically and render without changing their declaration catalogs. +package service + +import ( + "bytes" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestFilesRejectPlansFromDifferentGenerations verifies that aggregation cannot +// render declarations through a package catalog that did not plan them. +func TestFilesRejectPlansFromDifferentGenerations(t *testing.T) { + first := mustTestGeneration(t, "example.com/first/gen", nil) + second := mustTestGeneration(t, "example.com/second/gen", nil) + + _, err := Files(&Plan{generation: first}, &Plan{generation: second}) + require.ErrorContains(t, err, "different generations") +} + +type retainedServiceNameID struct { + root int + service string + symbol serviceSymbolID +} + +// TestServicePlansRenderByteIdenticallyAcrossRootAndServiceOrder catches +// shared-package names or section order that depend on discovery order. +func TestServicePlansRenderByteIdenticallyAcrossRootAndServiceOrder(t *testing.T) { + forwardPlans := orderedServicePlans(t, false) + forwardFiles, err := Files(forwardPlans...) + require.NoError(t, err) + forward := renderedServiceFiles(t, forwardFiles) + + reversePlans := orderedServicePlans(t, true) + reverseFiles, err := Files(reversePlans...) + require.NoError(t, err) + reverse := renderedServiceFiles(t, reverseFiles) + + requireRenderedServiceFilesEqual(t, forward, reverse) + requireRenderedServiceFile(t, forward, filepath.Join(codegen.Gendir, "types", "alpha_envelope.go")) + requireRenderedServiceFile(t, forward, filepath.Join(codegen.Gendir, "types", "beta_envelope.go")) + requireRenderedServiceFile(t, forward, filepath.Join(codegen.Gendir, "types", "omega_envelope.go")) + requireRenderedServiceFile(t, forward, filepath.Join(codegen.Gendir, "types", "unions.go")) + + compileFiles := append([]*codegen.File(nil), forwardFiles...) + for _, plan := range forwardPlans { + compileFiles = append(compileFiles, ExampleServiceFiles(plan)...) + } + compileGeneratedServiceFiles(t, compileFiles) +} + +// TestServicePlanRenderingIsPure catches renderers that rebuild analysis, +// replace retained declaration records, or append sections on a second read. +func TestServicePlanRenderingIsPure(t *testing.T) { + plans := orderedServicePlans(t, false) + before := retainedServiceNamePointers(plans) + firstFiles, err := Files(plans...) + require.NoError(t, err) + first := renderedServiceFiles(t, firstFiles) + + secondFiles, err := Files(plans...) + require.NoError(t, err) + second := renderedServiceFiles(t, secondFiles) + after := retainedServiceNamePointers(plans) + + require.Equal(t, first, second) + require.Len(t, after, len(before)) + for id, declaration := range before { + require.Same(t, declaration, after[id], "service symbol changed: %+v", id) + } +} + +// requireRenderedServiceFile reports a missing shared-package contribution +// without printing the complete generated output map. +func requireRenderedServiceFile(t *testing.T, files map[string][]byte, path string) { + t.Helper() + _, exists := files[path] + require.True(t, exists, "missing generated file %s", path) +} + +// requireRenderedServiceFilesEqual compares the same sorted output paths one +// at a time so an order-dependent package reports the precise changed file. +func requireRenderedServiceFilesEqual(t *testing.T, expected, actual map[string][]byte) { + t.Helper() + expectedPaths := make([]string, 0, len(expected)) + actualPaths := make([]string, 0, len(actual)) + for path := range expected { + expectedPaths = append(expectedPaths, path) + } + for path := range actual { + actualPaths = append(actualPaths, path) + } + sort.Strings(expectedPaths) + sort.Strings(actualPaths) + require.Equal(t, expectedPaths, actualPaths) + for _, path := range expectedPaths { + require.Equal(t, string(expected[path]), string(actual[path]), path) + } +} + +// orderedServicePlans builds equivalent fresh designs with both root and +// service discovery reversed, then runs collection, freeze, and link once. +func orderedServicePlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + first := orderedServiceRoot(t, reverse) + second := singleServiceRoot(t) + roots := []*expr.RootExpr{first, second} + if reverse { + roots[0], roots[1] = roots[1], roots[0] + } + evaluated := make([]eval.Root, len(roots)) + for index, root := range roots { + evaluated[index] = root + } + generation, err := codegen.NewGeneration("generated.local/gen", evaluated) + require.NoError(t, err) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +// orderedServiceRoot defines two services that emit distinct same-base unions +// into one relocated package. +func orderedServiceRoot(t *testing.T, reverse bool) *expr.RootExpr { + t.Helper() + return codegen.RunDSL(t, func() { + alpha := relocatedOrderType("AlphaEnvelope", "text", dsl.String) + omega := relocatedOrderType("OmegaEnvelope", "count", dsl.Int) + alphaService := func() { + dsl.Service("Alpha", func() { + dsl.Method("Read", func() { dsl.Payload(alpha) }) + }) + } + omegaService := func() { + dsl.Service("Omega", func() { + dsl.Method("Read", func() { dsl.Payload(omega) }) + }) + } + if reverse { + omegaService() + alphaService() + } else { + alphaService() + omegaService() + } + }) +} + +// singleServiceRoot defines the second root contributing to the same +// relocated package used by orderedServiceRoot. +func singleServiceRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return codegen.RunDSL(t, func() { + beta := relocatedOrderType("BetaEnvelope", "enabled", dsl.Boolean) + dsl.Service("Beta", func() { + dsl.Method("Read", func() { dsl.Payload(beta) }) + }) + }) +} + +// relocatedOrderType creates one force-generated type with a Value union in +// the shared generated types package. +func relocatedOrderType(name, branch string, dataType expr.DataType) expr.UserType { + return dsl.Type(name, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("Value", func() { + dsl.Attribute(branch, dataType) + }) + }) +} + +// renderedServiceFiles executes every retained section and indexes the exact +// bytes by output path, rejecting duplicate contributions. +func renderedServiceFiles(t *testing.T, files []*codegen.File) map[string][]byte { + t.Helper() + rendered := make(map[string][]byte, len(files)) + for _, file := range files { + _, duplicate := rendered[file.Path] + require.False(t, duplicate, "duplicate generated file %s", file.Path) + var buffer bytes.Buffer + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&buffer)) + } + rendered[file.Path] = bytes.Clone(buffer.Bytes()) + } + return rendered +} + +// retainedServiceNamePointers snapshots the exact declaration records held by +// each retained plan so rendering cannot replace them unnoticed. +func retainedServiceNamePointers(plans []*Plan) map[retainedServiceNameID]*codegen.NameDeclaration { + pointers := make(map[retainedServiceNameID]*codegen.NameDeclaration) + for rootIndex, plan := range plans { + for _, facts := range plan.facts.services { + for id, name := range facts.names { + pointers[retainedServiceNameID{ + root: rootIndex, + service: facts.service.Name, + symbol: id, + }] = name.declaration + } + } + } + return pointers +} diff --git a/codegen/service/service_test.go b/codegen/service/service_test.go index 2e0b23a17e..adecf2cbd7 100644 --- a/codegen/service/service_test.go +++ b/codegen/service/service_test.go @@ -1,9 +1,12 @@ +// This file verifies service render analysis and the generated service files +// built from its immutable data. package service import ( "bytes" "go/format" "path/filepath" + "slices" "strings" "testing" @@ -12,8 +15,674 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" ) +func TestServicesDataUsesFrozenPackageDeclarations(t *testing.T) { + var shared expr.UserType + root := codegen.RunDSL(t, func() { + shared = dsl.Type("Shared", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + first := dsl.Type("FirstPayload", func() { + dsl.Attribute("shared", shared) + }) + second := dsl.Type("SecondPayload", func() { + dsl.Attribute("shared", shared) + }) + dsl.Service("First", func() { + dsl.Method("Read", func() { + dsl.Payload(first) + }) + }) + dsl.Service("Second", func() { + dsl.Method("Read", func() { + dsl.Payload(second) + }) + }) + }) + + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + require.Panics(t, func() { plan.Services() }) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + services := plan.Services() + + first := services.Get("First") + second := services.Get("Second") + firstShared := findUserTypeData(first.userTypes, shared) + secondShared := findUserTypeData(second.userTypes, shared) + require.NotNil(t, firstShared) + require.NotNil(t, secondShared) + require.Same(t, firstShared.Declaration, secondShared.Declaration) + require.Len(t, first.unions, 1) + require.Len(t, second.unions, 1) + require.Same(t, first.unions[0].TypeDeclaration, second.unions[0].TypeDeclaration) + require.Same(t, first.unions[0].KindDeclaration, second.unions[0].KindDeclaration) + require.Equal(t, "Value", first.unions[0].Name) + require.Equal(t, "ValueKind", first.unions[0].KindName) + + _, err = generation.Package("goa.design/goa/example/types").DeclareUserType(shared) + require.ErrorContains(t, err, "frozen") +} + +// TestPlanOwnsNormalizedMethodNames verifies that semantic wrappers receive +// names from the service package catalog and collide only with local exact +// declarations. +func TestPlanOwnsNormalizedMethodNames(t *testing.T) { + var local expr.UserType + root := codegen.RunDSL(t, func() { + local = dsl.Type("UsePayload", func() { + dsl.Attribute("existing", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Existing", func() { + dsl.Payload(local) + }) + dsl.Method("Use", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + require.NoError(t, planTestServices(root, generation)) + require.NoError(t, generation.Freeze()) + + service := root.Service("Values") + wrapper := service.Method("Use").Payload.Type.(expr.UserType) + declaration, err := generation.Package("generated.local/gen/values").Type(wrapper) + require.NoError(t, err) + require.Equal(t, "UsePayload2", declaration.Name()) +} + +// TestPlanPreservesGeneratedPackageClaims verifies that service planning +// rejects distinct metadata spellings before path normalization can merge +// their declarations into one output package. +func TestPlanPreservesGeneratedPackageClaims(t *testing.T) { + tests := []struct { + name string + firstPath string + secondPath string + contains string + }{ + {"normalized collision", "types", "domain/../types", "normalize to import path"}, + {"portable collision", "Types", "types", "case-insensitive filesystem"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := codegen.RunDSL(t, func() { + first := dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", test.firstPath) + dsl.Attribute("value", dsl.String) + }) + second := dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", test.secondPath) + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("First", func() { dsl.Payload(first) }) + dsl.Method("Second", func() { dsl.Payload(second) }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + + err := planTestServices(root, generation) + require.ErrorContains(t, err, test.contains) + }) + } +} + +// TestPlanRejectsInvalidGeneratedPackageLocations verifies that relative Goa +// metadata cannot escape its generated module or use filesystem separators in +// a Go import path. +func TestPlanRejectsInvalidGeneratedPackageLocations(t *testing.T) { + tests := []struct { + name string + location string + }{ + {"absolute", "/outside"}, + {"escape", "../outside"}, + {"backslash", `domain\types`}, + {"colon", "domain:types"}, + {"space", "domain types"}, + {"control", "domain\x00types"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := codegen.RunDSL(t, func() { + value := dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", test.location) + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { dsl.Payload(value) }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + + require.Error(t, planTestServices(root, generation)) + }) + } +} + +// TestPlanIgnoresUnusedRelocatedTypes verifies that a type excluded from +// service output does not needlessly claim a package or contribute imports. +func TestPlanIgnoresUnusedRelocatedTypes(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Type("Unused", func() { + dsl.Meta("struct:pkg:path", "unused") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() {}) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + + require.NoError(t, planTestServices(root, generation)) + require.NoError(t, generation.Freeze()) +} + +// TestFilesUseCanonicalOwnedOutputDirectory verifies that a lone noncanonical +// metadata spelling emits the declaration beneath its owned canonical package. +func TestFilesUseCanonicalOwnedOutputDirectory(t *testing.T) { + root := codegen.RunDSL(t, func() { + value := dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", "domain/../types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { dsl.Payload(value) }) + }) + }) + plan := mustServicePlan(t, root) + + require.NotNil(t, findFile(mustServiceFiles(t, plan), + filepath.Join("gen", "types", "value.go"))) +} + +// TestServicesDataUsesRebuiltViewDeclarations verifies that planning and +// rendering can rebuild view expressions while sharing frozen declarations. +func TestServicesDataUsesRebuiltViewDeclarations(t *testing.T) { + var result *expr.ResultTypeExpr + root := codegen.RunDSL(t, func() { + result = dsl.ResultType("application/vnd.value", func() { + dsl.TypeName("Value") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(result) + }) + }) + }) + + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + views := mustClaimTestPackage(t, generation, "goa.design/goa/example/values/views") + plannedProjected, err := views.DerivedType(codegen.NewProjectedTypeID(result)) + require.NoError(t, err) + plannedViewed, err := views.DerivedType(codegen.NewViewedResultTypeID(result)) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + services := plan.Services() + service := services.Get("Values") + require.Len(t, service.projectedTypes, 1) + require.Len(t, service.viewedResultTypes, 1) + require.Same(t, plannedProjected, service.projectedTypes[0].Declaration) + require.Same(t, plannedViewed, service.viewedResultTypes[0].Declaration) + require.Equal(t, "ValueView", plannedProjected.Name()) + require.Equal(t, "Value", plannedViewed.Name()) +} + +func TestFilesEmitsPackageDeclarationsOnce(t *testing.T) { + root := codegen.RunDSL(t, testdata.PkgPathUnionNameScopeDSL) + files := mustServiceFiles(t, mustServicePlan(t, root)) + + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "first_value.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "second_value.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "third_value.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "unions.go"))) + + unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) + require.NotNil(t, unionFile) + code := renderSections(t, unionFile.SectionTemplates) + require.Equal(t, 1, strings.Count(code, "type Value struct {"), code) + require.Equal(t, 1, strings.Count(code, "type ValueKind string"), code) +} + +func TestFilesEmitsDifferentSameBaseUnionsWithFrozenNames(t *testing.T) { + root := codegen.RunDSL(t, func() { + first := dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + second := dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("number", dsl.Int) + }) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(first) + }) + }) + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(second) + }) + }) + }) + + files := mustServiceFiles(t, mustServicePlan(t, root)) + unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) + require.NotNil(t, unionFile) + code := renderSections(t, unionFile.SectionTemplates) + require.Equal(t, 1, strings.Count(code, "type Value struct {"), code) + require.Equal(t, 1, strings.Count(code, "type Value2 struct {"), code) + require.Equal(t, 1, strings.Count(code, "type ValueKind string"), code) + require.Equal(t, 1, strings.Count(code, "type Value2Kind string"), code) +} + +func TestFilesEmitsSharedPackagesOnceAcrossRoots(t *testing.T) { + var firstType expr.UserType + firstRoot := codegen.RunDSL(t, func() { + firstType = dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(firstType) + }) + }) + }) + var secondType expr.UserType + secondRoot := codegen.RunDSL(t, func() { + secondType = dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(secondType) + }) + }) + }) + + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + plans, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.NoError(t, err) + firstPlan, secondPlan := plans[0], plans[1] + firstUnion := expr.AsObject(firstType).Attribute("Value").Type.(*expr.Union) + secondUnion := expr.AsObject(secondType).Attribute("Value").Type.(*expr.Union) + generatedPackage := mustClaimTestPackage(t, generation, "goa.design/goa/example/types") + firstBranch, err := generatedPackage.UnionBranchType(firstUnion, "text") + require.NoError(t, err) + secondBranch, err := generatedPackage.UnionBranchType(secondUnion, "text") + require.NoError(t, err) + require.Same(t, firstBranch, secondBranch) + + require.NoError(t, generation.Freeze()) + require.NoError(t, firstPlan.Link()) + require.NoError(t, secondPlan.Link()) + files := mustServiceFiles(t, firstPlan, secondPlan) + + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "first.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "second.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "value_text.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "unions.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "first_service", "service.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "second_service", "service.go"))) +} + +func TestFilesEmitCanonicalSharedDeclarationAcrossRoots(t *testing.T) { + forwardPlans := sharedDeclarationPlans(t, false) + forwardFiles := mustServiceFiles(t, forwardPlans...) + sharedPath := filepath.Join("gen", "types", "shared.go") + require.Equal(t, 1, countFiles(forwardFiles, sharedPath)) + forward := renderSingleFileAtPath(t, forwardFiles, sharedPath) + require.Contains(t, forward, "// The canonical shared declaration.") + + reversePlans := sharedDeclarationPlans(t, true) + reverseFiles := mustServiceFiles(t, reversePlans...) + require.Equal(t, 1, countFiles(reverseFiles, sharedPath)) + require.Equal(t, forward, renderSingleFileAtPath(t, reverseFiles, sharedPath)) +} + +func TestNewPlansRejectConflictingSharedDeclarationEmissionCandidates(t *testing.T) { + firstRoot, secondRoot := conflictingSharedDeclarationRoots(t) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "conflicting generated type emission") +} + +// TestNewPlansAcceptEquivalentSharedDeclarationCopies proves compiler-created +// copies coalesce when every retained type fact is structurally identical. +func TestNewPlansAcceptEquivalentSharedDeclarationCopies(t *testing.T) { + firstRoot, secondRoot := copiedSharedDeclarationRoots(t, nil) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + plans, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + require.Equal(t, 1, countFiles(mustServiceFiles(t, plans...), filepath.Join("gen", "types", "shared.go"))) +} + +// TestNewPlansRejectSharedDeclarationLayoutConflicts proves a shared package +// declaration cannot silently select one compiler copy's field spelling. +func TestNewPlansRejectSharedDeclarationLayoutConflicts(t *testing.T) { + firstRoot, secondRoot := copiedSharedDeclarationRoots(t, func(copy expr.UserType) { + field := expr.AsObject(copy).Attribute("value") + field.AddMeta("struct:field:name", "OtherValue") + }) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "conflicting generated type emission") +} + +// TestNewPlansAcceptDistinctTransportValidationForSharedDeclaration proves +// validation does not become false service-file ownership. HTTP and gRPC own +// their validation programs; the shared service file owns only the Go layout. +func TestNewPlansAcceptDistinctTransportValidationForSharedDeclaration(t *testing.T) { + firstRoot, secondRoot := copiedSharedDeclarationRoots(t, func(copy expr.UserType) { + expr.AsObject(copy).Attribute("value").Validation = &expr.ValidationExpr{Pattern: "^[a-z]+$"} + }) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + plans, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + require.Equal(t, 1, countFiles(mustServiceFiles(t, plans...), filepath.Join("gen", "types", "shared.go"))) +} + +// TestNewPlansRejectSharedUnionBranchLayoutConflicts proves one canonical +// union declaration cannot select between differing retained branch layouts. +func TestNewPlansRejectSharedUnionBranchLayoutConflicts(t *testing.T) { + firstRoot, secondRoot := copiedSharedUnionRoots(t, func(union *expr.Union) { + union.Values[0].Attribute.Description = "a conflicting branch description" + }) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "conflicting generated union emission") +} + +func TestNewPlanRejectsPartialMultiRootPlanning(t *testing.T) { + firstRoot, secondRoot := sharedDeclarationRoots(t) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlan(firstRoot, generation, expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)) + require.ErrorContains(t, err, "requires all 2 generation roots") +} + +// sharedDeclarationPlans builds and links both service plans, optionally in +// reverse order. +func sharedDeclarationPlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + firstRoot, secondRoot := sharedDeclarationRoots(t) + roots := []*expr.RootExpr{firstRoot, secondRoot} + if reverse { + slices.Reverse(roots) + } + evaluated := make([]eval.Root, len(roots)) + for index, root := range roots { + evaluated[index] = root + } + generation := mustTestGeneration(t, "goa.design/goa/example", evaluated) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +// sharedDeclarationRoots builds two services that use the same type declared +// by the first service. +func sharedDeclarationRoots(t *testing.T) (*expr.RootExpr, *expr.RootExpr) { + t.Helper() + var shared expr.UserType + firstRoot := codegen.RunDSL(t, func() { + shared = dsl.Type("Shared", func() { + dsl.Description("The canonical shared declaration.") + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(shared) + }) + }) + }) + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(shared) + }) + }) + }) + return firstRoot, secondRoot +} + +// conflictingSharedDeclarationRoots builds two services whose copies of the +// same type have different definitions. +func conflictingSharedDeclarationRoots(t *testing.T) (*expr.RootExpr, *expr.RootExpr) { + t.Helper() + var shared expr.UserType + firstRoot := codegen.RunDSL(t, func() { + shared = dsl.Type("Shared", func() { + dsl.Description("The first retained declaration.") + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(shared) + }) + }) + }) + conflicting := shared.Dup(expr.DupAtt(shared.Attribute())) + conflicting.Attribute().Description = "The conflicting retained declaration." + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(conflicting) + }) + }) + }) + return firstRoot, secondRoot +} + +// copiedSharedDeclarationRoots returns two roots whose compiler copies share +// one authored origin and therefore one generated declaration. +func copiedSharedDeclarationRoots(t *testing.T, mutate func(expr.UserType)) (*expr.RootExpr, *expr.RootExpr) { + t.Helper() + var shared expr.UserType + firstRoot := codegen.RunDSL(t, func() { + shared = dsl.Type("Shared", func() { + dsl.Description("The canonical shared declaration.") + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { dsl.Payload(shared) }) + }) + }) + copy := shared.Dup(expr.DupAtt(shared.Attribute())) + if mutate != nil { + mutate(copy) + } + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { dsl.Payload(copy) }) + }) + }) + return firstRoot, secondRoot +} + +// copiedSharedUnionRoots returns two roots whose equal union identities bind +// the same generated declaration while retaining independent branch facts. +func copiedSharedUnionRoots(t *testing.T, mutate func(*expr.Union)) (*expr.RootExpr, *expr.RootExpr) { + t.Helper() + var container expr.UserType + firstRoot := codegen.RunDSL(t, func() { + container = dsl.Type("Container", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { dsl.Payload(container) }) + }) + }) + copy := container.Dup(expr.DupAtt(container.Attribute())) + union := expr.AsObject(copy).Attribute("value").Type.(*expr.Union) + if mutate != nil { + mutate(union) + } + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { dsl.Payload(copy) }) + }) + }) + return firstRoot, secondRoot +} + +func TestGeneratedUnionBranchCollisionDoesNotCanonicalizeToRootType(t *testing.T) { + var ( + exact expr.UserType + container expr.UserType + ) + root := codegen.RunDSL(t, func() { + exact = dsl.Type("Value-Text", dsl.String, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + }) + container = dsl.Type("Container", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("Collision", func() { + dsl.Method("Read", func() { + dsl.Payload(container) + }) + }) + }) + + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + union := expr.AsObject(container).Attribute("Value").Type.(*expr.Union) + generatedPackage := mustClaimTestPackage(t, generation, "goa.design/goa/example/types") + exactDeclaration, err := generatedPackage.UserType(exact) + require.NoError(t, err) + branchDeclaration, err := generatedPackage.UnionBranchType(union, "text") + require.NoError(t, err) + require.NotSame(t, exactDeclaration, branchDeclaration) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "ValueText", exactDeclaration.Name()) + require.Equal(t, "ValueText2", branchDeclaration.Name()) + require.NoError(t, plan.Link()) + typeFile := findFile( + mustServiceFiles(t, plan), + filepath.Join("gen", "types", "value_text.go"), + ) + require.NotNil(t, typeFile) + code := renderSections(t, typeFile.SectionTemplates) + require.Contains(t, code, "type ValueText string") + require.Contains(t, code, "type ValueText2 string") +} + +// TestForcedRelocatedTypesUseTheirDeclaringPackageForNestedReferences verifies +// that a generated types package can render one forced type nested in another +// without importing the package currently being written. +func TestForcedRelocatedTypesUseTheirDeclaringPackageForNestedReferences(t *testing.T) { + root := codegen.RunDSL(t, func() { + inner := dsl.Type("Inner", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.Attribute("value", dsl.String) + }) + dsl.Type("Outer", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.Attribute("inner", inner) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() {}) + }) + }) + + plan := retainedServicePlanForPackage(t, root) + typeFile := findFile( + mustServiceFiles(t, plan), + filepath.Join("gen", "types", "outer.go"), + ) + require.NotNil(t, typeFile) + code := renderSections(t, typeFile.SectionTemplates) + require.Contains(t, code, "Inner *Inner") + files := mustServiceFiles(t, plan) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, files) +} + func TestService(t *testing.T) { cases := []struct { Name string @@ -40,6 +709,7 @@ func TestService(t *testing.T) { {"service-service-level-error", testdata.ServiceErrorDSL}, {"service-custom-errors", testdata.CustomErrorsDSL}, {"service-custom-errors-custom-field", testdata.CustomErrorsCustomFieldDSL}, + {"service-repeated-inline-errors", testdata.RepeatedInlineErrorsDSL}, {"service-force-generate-type", testdata.ForceGenerateTypeDSL}, {"service-force-generate-type-explicit", testdata.ForceGenerateTypeExplicitDSL}, {"service-streaming-result", testdata.StreamingResultMethodDSL}, @@ -64,19 +734,12 @@ func TestService(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := mustServiceFiles(t, plan) require.Greater(t, len(files), 0) - // Generate the code - buf := new(bytes.Buffer) - for _, s := range files[0].SectionTemplates[1:] { - require.NoError(t, s.Write(buf)) - } - bs, err := format.Source(buf.Bytes()) - require.NoError(t, err, buf.String()) - code := string(bs) + code := renderServiceGolden(t, files, files[0]) // Compare with golden file testutil.AssertGo(t, "testdata/golden/service_"+c.Name+".go.golden", code) @@ -89,6 +752,7 @@ func TestStructPkgPath(t *testing.T) { recursiveFooPath := filepath.Join("gen", "foo", "recursive_foo.go") barPath := filepath.Join("gen", "bar", "bar.go") bazPath := filepath.Join("gen", "baz", "baz.go") + sharedPath := filepath.Join("gen", "shared", "shared.go") cases := []struct { Name string DSL func() @@ -101,32 +765,26 @@ func TestStructPkgPath(t *testing.T) { {"multiple", testdata.PkgPathMultipleDSL, []string{barPath, bazPath}}, {"nopkg", testdata.PkgPathNoDirDSL, nil}, {"dupes", testdata.PkgPathDupeDSL, []string{fooPath}}, + {"shared_roles", testdata.PkgPathSharedRolesDSL, []string{sharedPath}}, {"payload_attribute", testdata.PkgPathPayloadAttributeDSL, []string{fooPath}}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - userTypePkgs := make(map[string][]string) root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) - files := Files("goa.design/goa/example", root.Services[0], services, userTypePkgs) - - // Check file count - expectedFiles := len(c.TypeFiles) + 1 - require.Len(t, files, expectedFiles, "unexpected number of files") + plan := mustServicePlan(t, root) + services := plan.Services() + files := mustServiceFiles(t, plan) - // First file is always the service file - buf := new(bytes.Buffer) - for _, s := range files[0].SectionTemplates[1:] { - require.NoError(t, s.Write(buf)) - } - bs, err := format.Source(buf.Bytes()) - require.NoError(t, err) - testutil.AssertGo(t, "testdata/golden/pkg_path_"+c.Name+"_service.go.golden", string(bs)) + serviceFile := findFile(files, filepath.Join(codegen.Gendir, services.Get(root.Services[0].Name).PathName, "service.go")) + require.NotNil(t, serviceFile) + testutil.AssertGo(t, "testdata/golden/pkg_path_"+c.Name+"_service.go.golden", renderServiceGolden(t, files, serviceFile)) // Type files - for i, typeFile := range c.TypeFiles { + for _, typeFile := range c.TypeFiles { + file := findFile(files, typeFile) + require.NotNil(t, file) buf := new(bytes.Buffer) - for _, s := range files[i+1].SectionTemplates[1:] { + for _, s := range file.SectionTemplates[1:] { require.NoError(t, s.Write(buf)) } bs, err := format.Source(buf.Bytes()) @@ -137,7 +795,7 @@ func TestStructPkgPath(t *testing.T) { // For dupes case, test the second service if c.Name == "dupes" && len(root.Services) > 1 { - files = Files("goa.design/goa/example", root.Services[1], services, userTypePkgs) + files = serviceFiles(plan, plan.facts.services[1]) require.Len(t, files, 1) buf := new(bytes.Buffer) for _, s := range files[0].SectionTemplates[1:] { @@ -151,25 +809,66 @@ func TestStructPkgPath(t *testing.T) { } } +func TestRelocatedTypeDescriptions(t *testing.T) { + cases := []struct { + name string + dsl func() + path string + want string + }{ + { + name: "payload and result", + dsl: testdata.PkgPathDSL, + path: filepath.Join("gen", "foo", "foo.go"), + want: "Foo is the payload and result type of the PkgPathMethod service A method.", + }, + { + name: "nested only", + dsl: testdata.PkgPathArrayDSL, + path: filepath.Join("gen", "foo", "foo.go"), + want: "Foo is a named type defined in the service design.", + }, + { + name: "all method roles", + dsl: testdata.PkgPathSharedRolesDSL, + path: filepath.Join("gen", "shared", "shared.go"), + want: "Shared is the payload, streaming payload, result, and streaming result type\n// of the PkgPathSharedRoles service Exchange method.", + }, + { + name: "several methods and services", + dsl: testdata.PkgPathDupeDSL, + path: filepath.Join("gen", "foo", "foo.go"), + want: "Foo is used by these service methods:\n" + + "// - PkgPathDupeMethod A: payload and result\n" + + "// - PkgPathDupeMethod B: payload and result\n" + + "// - PkgPathDupeMethod2 A: payload and result\n" + + "// - PkgPathDupeMethod2 B: payload and result", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + root := codegen.RunDSL(t, test.dsl) + plan := mustServicePlan(t, root) + file := findFile(mustServiceFiles(t, plan), test.path) + require.NotNil(t, file) + require.Contains(t, renderSections(t, file.SectionTemplates), test.want) + }) + } +} + func TestStructPkgPath_UnionImportsJSON(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionDSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := mustServiceFiles(t, plan) require.GreaterOrEqual(t, len(files), 2, "expected at least service.go + one struct:pkg:path file") - var typeFile *codegen.File - for _, f := range files { - if strings.HasSuffix(f.Path, filepath.Join("gen", "types", "type_with_union.go")) { - typeFile = f - break - } - } - require.NotNil(t, typeFile, "expected generated type file for struct:pkg:path type_with_union") + unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) + require.NotNil(t, unionFile, "expected generated union file in struct:pkg:path package") buf := new(bytes.Buffer) - for _, s := range typeFile.SectionTemplates { + for _, s := range unionFile.SectionTemplates { require.NoError(t, s.Write(buf)) } code := buf.String() @@ -177,25 +876,151 @@ func TestStructPkgPath_UnionImportsJSON(t *testing.T) { require.Contains(t, code, "\"encoding/json\"", "expected encoding/json import in generated file:\n%s", code) } +func TestStructPkgPath_UnionNamesSharePackageScopeAcrossServices(t *testing.T) { + root := codegen.RunDSL(t, testdata.PkgPathUnionNameScopeDSL) + var generated strings.Builder + files := mustServiceFiles(t, mustServicePlan(t, root)) + for _, file := range files { + if !strings.Contains(file.Path, filepath.Join("gen", "types")) { + continue + } + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&generated)) + } + } + + code := generated.String() + require.Equal(t, 1, strings.Count(code, "type Value struct {"), code) + require.Equal(t, 1, strings.Count(code, "type ValueKind string"), code) + firstUsesValue := unionFieldType(code, "FirstValue") + secondUsesValue := unionFieldType(code, "SecondValue") + thirdUsesValue := unionFieldType(code, "ThirdValue") + require.Equal(t, []string{"Value", "Value", "Value"}, []string{firstUsesValue, secondUsesValue, thirdUsesValue}) +} + +// unionFieldType returns the generated type of the Value field in the named +// struct. +func unionFieldType(code, owner string) string { + prefix := "type " + owner + " struct {\n\tValue " + start := strings.Index(code, prefix) + if start == -1 { + return "" + } + start += len(prefix) + end := strings.IndexByte(code[start:], '\n') + if end == -1 { + return "" + } + return code[start : start+end] +} + +// mustServicePlan runs the complete retained-plan lifecycle used by service +// renderer tests. +func mustServicePlan(t *testing.T, root *expr.RootExpr) *Plan { + t.Helper() + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + return plan +} + +// mustServiceFiles renders linked plans or fails the calling test. +func mustServiceFiles(t *testing.T, plans ...*Plan) []*codegen.File { + t.Helper() + files, err := Files(plans...) + require.NoError(t, err) + return files +} + +// countFiles returns how many generated files have the given path. +func countFiles(files []*codegen.File, path string) int { + count := 0 + for _, file := range files { + if file.Path == path { + count++ + } + } + return count +} + +// findFile returns the generated file with path, or nil when no file matches. +func findFile(files []*codegen.File, path string) *codegen.File { + for _, file := range files { + if file.Path == path { + return file + } + } + return nil +} + +// findUserTypeData returns the render data for userType. +func findUserTypeData(types []*UserTypeData, userType expr.UserType) *UserTypeData { + for _, data := range types { + if data.Type == userType { + return data + } + } + return nil +} + +// renderSections renders sections without writing a generated file. +func renderSections(t *testing.T, sections []*codegen.SectionTemplate) string { + t.Helper() + var rendered strings.Builder + for _, section := range sections { + require.NoError(t, section.Write(&rendered)) + } + return rendered.String() +} + +// renderServiceGolden reconstructs the former single-file declaration order +// so existing service golden assertions remain unchanged after unions move to +// their package-owned unions.go file. +func renderServiceGolden(t *testing.T, files []*codegen.File, serviceFile *codegen.File) string { + t.Helper() + sections := append([]*codegen.SectionTemplate(nil), serviceFile.SectionTemplates[1:]...) + unionFile := findFile(files, filepath.Join(filepath.Dir(serviceFile.Path), "unions.go")) + if unionFile != nil { + insertAt := len(sections) + for i, section := range sections { + switch section.Name { + case "error-init-func", "viewed-result-type-to-service-result-type", + "service-result-type-to-viewed-result-type", "projected-type-to-service-type", + "service-type-to-projected-type", "transform-helpers": + insertAt = i + } + if insertAt != len(sections) { + break + } + } + sections = append(sections, make([]*codegen.SectionTemplate, len(unionFile.SectionTemplates)-1)...) + copy(sections[insertAt+len(unionFile.SectionTemplates)-1:], sections[insertAt:]) + copy(sections[insertAt:], unionFile.SectionTemplates[1:]) + } + buf := new(bytes.Buffer) + for _, section := range sections { + require.NoError(t, section.Write(buf)) + } + formatted, err := format.Source(buf.Bytes()) + require.NoError(t, err, buf.String()) + return string(formatted) +} + func TestStructPkgPath_UnionJSONFieldBranchesGenerateAliases(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionJSONFieldDSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := mustServiceFiles(t, plan) require.GreaterOrEqual(t, len(files), 2, "expected at least service.go + one struct:pkg:path file") - var typeFile *codegen.File - for _, f := range files { - if strings.HasSuffix(f.Path, filepath.Join("gen", "types", "type_with_json_field_union.go")) { - typeFile = f - break - } - } - require.NotNil(t, typeFile, "expected generated type file for struct:pkg:path type_with_json_field_union") + unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) + require.NotNil(t, unionFile, "expected package-owned union file") buf := new(bytes.Buffer) - for _, s := range typeFile.SectionTemplates { + for _, s := range unionFile.SectionTemplates { require.NoError(t, s.Write(buf)) } code := buf.String() @@ -217,23 +1042,26 @@ func TestStructPkgPath_UnionJSONFieldBranchesGenerateAliases(t *testing.T) { func TestStructPkgPath_ExtendedUnionGeneratedInEachOwningPackage(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathExtendedUnionDSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := mustServiceFiles(t, plan) require.GreaterOrEqual(t, len(files), 2) - var serviceFile, sharedTypeFile *codegen.File + var serviceFile, localUnionFile, sharedUnionFile *codegen.File for _, f := range files { switch { case strings.HasSuffix(f.Path, filepath.Join("gen", "pkg_path_extended_union", "service.go")): serviceFile = f - case strings.HasSuffix(f.Path, filepath.Join("gen", "types", "equipment_scope.go")): - sharedTypeFile = f + case strings.HasSuffix(f.Path, filepath.Join("gen", "pkg_path_extended_union", "unions.go")): + localUnionFile = f + case strings.HasSuffix(f.Path, filepath.Join("gen", "types", "unions.go")): + sharedUnionFile = f } } require.NotNil(t, serviceFile) - require.NotNil(t, sharedTypeFile) + require.NotNil(t, localUnionFile) + require.NotNil(t, sharedUnionFile) render := func(file *codegen.File) string { buf := new(bytes.Buffer) @@ -245,8 +1073,9 @@ func TestStructPkgPath_ExtendedUnionGeneratedInEachOwningPackage(t *testing.T) { return string(code) } serviceCode := render(serviceFile) - sharedTypeCode := render(sharedTypeFile) + localUnionCode := render(localUnionFile) + sharedUnionCode := render(sharedUnionFile) require.Contains(t, serviceCode, "Scope Scope") - require.Contains(t, serviceCode, "type Scope struct") - require.Contains(t, sharedTypeCode, "type Scope struct") + require.Contains(t, localUnionCode, "type Scope struct") + require.Contains(t, sharedUnionCode, "type Scope struct") } diff --git a/codegen/service/templates.go b/codegen/service/templates.go index 8359ee40e7..3ea0f22e2e 100644 --- a/codegen/service/templates.go +++ b/codegen/service/templates.go @@ -36,7 +36,6 @@ const ( exampleServiceInitT = "example_service_init" exampleSecurityAuthfuncsT = "example_security_authfuncs" endpointT = "endpoint" - jsonrpcHandleStreamT = "jsonrpc_handle_stream" // Service templates serviceT = "service" diff --git a/codegen/service/templates/client_interceptor_stream_wrapper_types.go.tpl b/codegen/service/templates/client_interceptor_stream_wrapper_types.go.tpl index 9a250d08f6..2190de264f 100644 --- a/codegen/service/templates/client_interceptor_stream_wrapper_types.go.tpl +++ b/codegen/service/templates/client_interceptor_stream_wrapper_types.go.tpl @@ -1,7 +1,7 @@ {{- range .WrappedClientStreams }} -{{ comment (printf "wrapped%s is a client interceptor wrapper for the %s stream." .Interface .Interface) }} -type wrapped{{ .Interface }} struct { +{{ comment (printf "%s is a client interceptor wrapper for the %s stream." .WrapperDeclaration.Name .InterfaceDeclaration.Name) }} +type {{ .WrapperDeclaration.Name }} struct { ctx context.Context {{- if ne .SendTypeRef "" }} sendWithContext func(context.Context, {{ .SendTypeRef }}) error @@ -9,6 +9,6 @@ type wrapped{{ .Interface }} struct { {{- if ne .RecvTypeRef "" }} recvWithContext func(context.Context) ({{ .RecvTypeRef }}, error) {{- end }} - stream {{ .Interface }} + stream {{ .InterfaceDeclaration.Name }} } {{- end }} diff --git a/codegen/service/templates/client_interceptor_stream_wrappers.go.tpl b/codegen/service/templates/client_interceptor_stream_wrappers.go.tpl index 555960d916..adbdccf58f 100644 --- a/codegen/service/templates/client_interceptor_stream_wrappers.go.tpl +++ b/codegen/service/templates/client_interceptor_stream_wrappers.go.tpl @@ -3,17 +3,17 @@ {{- if ne .SendTypeRef "" }} {{ comment (print "Unwrap returns the underlying stream type.") }} -func (w *wrapped{{ .Interface }}) Unwrap() any { +func (w *{{ .WrapperDeclaration.Name }}) Unwrap() any { return w.stream } -{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor." .SendName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { +{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor." .SendName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { return w.SendWithContext(w.ctx, v) } -{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor with context." .SendWithContextName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { +{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor with context." .SendWithContextName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { if w.sendWithContext == nil { return w.stream.{{ .SendWithContextName }}(ctx, v) } @@ -22,13 +22,13 @@ func (w *wrapped{{ .Interface }}) {{ .SendWithContextName }}(ctx context.Context {{- end }} {{- if ne .RecvTypeRef "" }} -{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor." .RecvName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { +{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor." .RecvName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { return w.RecvWithContext(w.ctx) } -{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor with context." .RecvWithContextName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { +{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor with context." .RecvWithContextName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { if w.recvWithContext == nil { return w.stream.{{ .RecvWithContextName }}(ctx) } @@ -38,7 +38,7 @@ func (w *wrapped{{ .Interface }}) {{ .RecvWithContextName }}(ctx context.Context {{- if .MustClose }} // Close closes the stream. -func (w *wrapped{{ .Interface }}) Close() error { +func (w *{{ .WrapperDeclaration.Name }}) Close() error { return w.stream.Close() } {{- end }} diff --git a/codegen/service/templates/client_interceptor_wrappers.go.tpl b/codegen/service/templates/client_interceptor_wrappers.go.tpl index c01590d59c..91e2212147 100644 --- a/codegen/service/templates/client_interceptor_wrappers.go.tpl +++ b/codegen/service/templates/client_interceptor_wrappers.go.tpl @@ -1,17 +1,14 @@ -{{- range .ClientInterceptors }} +{{- range .Interceptors }} {{- $interceptor := . }} {{- range .Methods }} -{{ comment (printf "wrapClient%s%s applies the %s client interceptor to endpoints." $interceptor.Name .MethodName $interceptor.DesignName) }} -func wrapClient{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { +{{ comment (printf "%s applies the %s client interceptor to endpoints." .ClientWrapperDeclaration.Name $interceptor.DesignName) }} +func {{ .ClientWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.InterceptorsDeclaration.Name }}) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { {{- if or $interceptor.HasStreamingPayloadAccess $interceptor.HasStreamingResultAccess }} {{- if $interceptor.HasPayloadAccess }} - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &{{ .ClientUnaryInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{rawPayload: req}, } res, err := i.{{ $interceptor.Name }}(ctx, info, endpoint) {{- else }} @@ -21,15 +18,12 @@ func wrapClient{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i return res, err } stream := res.({{ .ClientStream.Interface }}) - return &wrapped{{ .ClientStream.Interface }}{ + return &{{ .ClientStream.WrapperDeclaration.Name }}{ ctx: ctx, {{- if $interceptor.HasStreamingPayloadAccess }} sendWithContext: func(ctx context.Context, req {{ .ClientStream.SendTypeRef }}) error { - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &{{ .StreamingSendInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{rawPayload: req}, } _, err := i.{{ $interceptor.Name }}(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.({{ .ClientStream.SendTypeRef }}) @@ -40,10 +34,8 @@ func wrapClient{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i {{- end }} {{- if $interceptor.HasStreamingResultAccess }} recvWithContext: func(ctx context.Context) ({{ .ClientStream.RecvTypeRef }}, error) { - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorStreamingRecv, + info := &{{ .StreamingRecvInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{}, } res, err := i.{{ $interceptor.Name }}(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.{{ .ClientStream.RecvWithContextName }}(ctx) @@ -55,11 +47,8 @@ func wrapClient{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i stream: stream, }, nil {{- else }} - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &{{ .ClientUnaryInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{rawPayload: req}, } return i.{{ $interceptor.Name }}(ctx, info, endpoint) {{- end }} diff --git a/codegen/service/templates/client_interceptors.go.tpl b/codegen/service/templates/client_interceptors.go.tpl index 9528e3d123..50725935b7 100644 --- a/codegen/service/templates/client_interceptors.go.tpl +++ b/codegen/service/templates/client_interceptors.go.tpl @@ -2,11 +2,11 @@ // Client interceptors execute after the payload is encoded and before the request // is sent to the server. The implementation is responsible for calling next to // complete the request. -type ClientInterceptors interface { +type {{ .ClientInterceptorsDeclaration.Name }} interface { {{- range .ClientInterceptors }} {{- if .Description }} {{ comment .Description }} {{- end }} - {{ .Name }}(ctx context.Context, info *{{ .Name }}Info, next goa.Endpoint) (any, error) + {{ .Name }}(ctx context.Context, info {{ .InfoDeclaration.Name }}, next goa.Endpoint) (any, error) {{- end }} } diff --git a/codegen/service/templates/client_wrappers.go.tpl b/codegen/service/templates/client_wrappers.go.tpl index bbd1a551d5..eee2c6f8bc 100644 --- a/codegen/service/templates/client_wrappers.go.tpl +++ b/codegen/service/templates/client_wrappers.go.tpl @@ -1,9 +1,9 @@ -{{ comment (printf "Wrap%sClientEndpoint wraps the %s endpoint with the client interceptors defined in the design." .MethodVarName .Method) }} -func Wrap{{ .MethodVarName }}ClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { +{{ comment (printf "%s wraps the %s endpoint with the client interceptors defined in the design." .Declaration.Name .Method) }} +func {{ .Declaration.Name }}(endpoint goa.Endpoint, i {{ .InterceptorsDeclaration.Name }}) goa.Endpoint { if i != nil { - {{- range .Interceptors }} - endpoint = wrapClient{{ $.MethodVarName }}{{ . }}(endpoint, i) + {{- range .Wrappers }} + endpoint = {{ .Name }}(endpoint, i) {{- end }} } return endpoint diff --git a/codegen/service/templates/endpoint.go.tpl b/codegen/service/templates/endpoint.go.tpl index 95a459bea3..e15f537a4c 100644 --- a/codegen/service/templates/endpoint.go.tpl +++ b/codegen/service/templates/endpoint.go.tpl @@ -1,14 +1,18 @@ {{ comment .Description }} {{- if .ServerStream }} -func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}, stream {{ .StreamInterface }}) (err error) { + {{- if .HasMixedResults }} +func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}, stream {{ .StreamInterface }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) { + {{- else }} +func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}, stream {{ .StreamInterface }}) (err error) { + {{- end }} {{- else }} -func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}{{ if .SkipRequestBodyEncodeDecode }}, req io.ReadCloser{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}{{ if .SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) { +func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}{{ if .SkipRequestBodyEncodeDecode }}, req io.ReadCloser{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}{{ if .SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) { {{- end }} {{- if .SkipRequestBodyEncodeDecode }} // req is the HTTP request body stream. defer req.Close() {{- end }} -{{- if and .Result .ResultIsStruct (not .ServerStream) }} +{{- if and .Result .ResultIsStruct (or (not .ServerStream) .HasMixedResults) }} res = &{{ .ResultFullName }}{} {{- end }} {{- if .SkipResponseBodyEncodeDecode }} @@ -17,7 +21,9 @@ func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .Pay {{- end }} {{- if .ViewedResult }} {{- if not .ViewedResult.ViewName }} - {{- if .ServerStream }} + {{- if .HasMixedResults }} + view = {{ printf "%q" .ResultView }} + {{- else if .ServerStream }} stream.SetView({{ printf "%q" .ResultView }}) {{- else }} view = {{ printf "%q" .ResultView }} @@ -25,16 +31,5 @@ func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .Pay {{- end }} {{- end }} log.Printf(ctx, "{{ .ServiceVarName }}.{{ .Name }}") -{{- if and .ServerStream .IsJSONRPC .ResultFullName }} - // Minimal example: emit one progress notification and one final response - { - // Progress notification (no ID) - notif := {{ if .ResultIsStruct }}&{{ .ResultFullName }}{}{{ else }}{{ .ResultFullName }}({{ if eq .ResultFullName "string" }}"progress"{{ else }}0{{ end }}){{ end }} - if err := stream.Send(ctx, notif); err != nil { return err } - // Final response - final := {{ if .ResultIsStruct }}&{{ .ResultFullName }}{}{{ else }}{{ .ResultFullName }}({{ if eq .ResultFullName "string" }}"done"{{ else }}0{{ end }}){{ end }} - return stream.SendAndClose(ctx, final) - } -{{- end }} return } diff --git a/codegen/service/templates/endpoint_wrappers.go.tpl b/codegen/service/templates/endpoint_wrappers.go.tpl index fee83a87ad..13f0164f39 100644 --- a/codegen/service/templates/endpoint_wrappers.go.tpl +++ b/codegen/service/templates/endpoint_wrappers.go.tpl @@ -1,8 +1,8 @@ -{{ comment (printf "Wrap%sEndpoint wraps the %s endpoint with the server-side interceptors defined in the design." .MethodVarName .Method) }} -func Wrap{{ .MethodVarName }}Endpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { +{{ comment (printf "%s wraps the %s endpoint with the server-side interceptors defined in the design." .Declaration.Name .Method) }} +func {{ .Declaration.Name }}(endpoint goa.Endpoint, i {{ .InterceptorsDeclaration.Name }}) goa.Endpoint { if i != nil { - {{- range .Interceptors }} - endpoint = wrap{{ $.MethodVarName }}{{ . }}(endpoint, i) + {{- range .Wrappers }} + endpoint = {{ .Name }}(endpoint, i) {{- end }} } return endpoint diff --git a/codegen/service/templates/error.go.tpl b/codegen/service/templates/error.go.tpl index d995faa1f5..74c9aa7a96 100644 --- a/codegen/service/templates/error.go.tpl +++ b/codegen/service/templates/error.go.tpl @@ -12,5 +12,5 @@ func (e {{ .Ref }}) ErrorName() string { // GoaErrorName returns the error name. func (e {{ .Ref }}) GoaErrorName() string { - return {{ errorName . }} + return {{ .ErrorName }} } diff --git a/codegen/service/templates/error_init.go.tpl b/codegen/service/templates/error_init.go.tpl index 8b474689b1..fda57b0125 100644 --- a/codegen/service/templates/error_init.go.tpl +++ b/codegen/service/templates/error_init.go.tpl @@ -1,4 +1,4 @@ -{{ printf "%s builds a %s from an error." .Name .TypeName | comment }} -func {{ .Name }}(err error) {{ .TypeRef }} { +{{ printf "%s builds a %s from an error." .Declaration.Name .TypeName | comment }} +func {{ .Declaration.Name }}(err error) {{ .TypeRef }} { return goa.NewServiceError(err, {{ printf "%q" .ErrName }}, {{ printf "%v" .Timeout }}, {{ printf "%v" .Temporary}}, {{ printf "%v" .Fault}}) } diff --git a/codegen/service/templates/example_client_interceptor.go.tpl b/codegen/service/templates/example_client_interceptor.go.tpl index 4dad48f184..d77925209a 100644 --- a/codegen/service/templates/example_client_interceptor.go.tpl +++ b/codegen/service/templates/example_client_interceptor.go.tpl @@ -1,17 +1,17 @@ -// {{ .StructName }}ClientInterceptors implements the client interceptors for the {{ .ServiceName }} service. -type {{ .StructName }}ClientInterceptors struct { +// {{ .StructDeclaration.Name }} implements the client interceptors for the {{ .ServiceName }} service. +type {{ .StructDeclaration.Name }} struct { } -// New{{ .StructName }}ClientInterceptors creates a new client interceptor for the {{ .ServiceName }} service. -func New{{ .StructName }}ClientInterceptors() *{{ .StructName }}ClientInterceptors { - return &{{ .StructName }}ClientInterceptors{} +// {{ .ConstructorDeclaration.Name }} creates a new client interceptor for the {{ .ServiceName }} service. +func {{ .ConstructorDeclaration.Name }}() *{{ .StructDeclaration.Name }} { + return &{{ .StructDeclaration.Name }}{} } -{{- range .ClientInterceptors }} +{{- range .Interceptors }} {{- if .Description }} {{ comment .Description }} {{- end }} -func (i *{{ $.StructName }}ClientInterceptors) {{ .Name }}(ctx context.Context, info *{{ $.PkgName }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { +func (i *{{ $.StructDeclaration.Name }}) {{ .Name }}(ctx context.Context, info {{ $.ServicePkg }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[{{ .Name }}] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/templates/example_security_authfuncs.go.tpl b/codegen/service/templates/example_security_authfuncs.go.tpl index 5eb760e38e..ba06534ce6 100644 --- a/codegen/service/templates/example_security_authfuncs.go.tpl +++ b/codegen/service/templates/example_security_authfuncs.go.tpl @@ -1,6 +1,6 @@ {{ range .Schemes }} {{ printf "%sAuth implements the authorization logic for service %q for the %q security scheme." .Type $.Name .SchemeName | comment }} -func (s *{{ $.VarName }}srvc) {{ .Type }}Auth(ctx context.Context, {{ if eq .Type "Basic" }}user, pass{{ else if eq .Type "APIKey" }}key{{ else }}token{{ end }} string, scheme *security.{{ .Type }}Scheme) (context.Context, error) { +func (s *{{ $.ExampleStructDeclaration.Name }}) {{ .Type }}Auth(ctx context.Context, {{ if eq .Type "Basic" }}user, pass{{ else if eq .Type "APIKey" }}key{{ else }}token{{ end }} string, scheme *security.{{ .Type }}Scheme) (context.Context, error) { // // TBD: add authorization logic. // diff --git a/codegen/service/templates/example_server_interceptor.go.tpl b/codegen/service/templates/example_server_interceptor.go.tpl index 9e1ed5086e..172f9b449c 100644 --- a/codegen/service/templates/example_server_interceptor.go.tpl +++ b/codegen/service/templates/example_server_interceptor.go.tpl @@ -1,17 +1,17 @@ -// {{ .StructName }}ServerInterceptors implements the server interceptor for the {{ .ServiceName }} service. -type {{ .StructName }}ServerInterceptors struct { +// {{ .StructDeclaration.Name }} implements the server interceptor for the {{ .ServiceName }} service. +type {{ .StructDeclaration.Name }} struct { } -// New{{ .StructName }}ServerInterceptors creates a new server interceptor for the {{ .ServiceName }} service. -func New{{ .StructName }}ServerInterceptors() *{{ .StructName }}ServerInterceptors { - return &{{ .StructName }}ServerInterceptors{} +// {{ .ConstructorDeclaration.Name }} creates a new server interceptor for the {{ .ServiceName }} service. +func {{ .ConstructorDeclaration.Name }}() *{{ .StructDeclaration.Name }} { + return &{{ .StructDeclaration.Name }}{} } -{{- range .ServerInterceptors }} +{{- range .Interceptors }} {{- if .Description }} {{ comment .Description }} {{- end }} -func (i *{{ $.StructName }}ServerInterceptors) {{ .Name }}(ctx context.Context, info *{{ $.PkgName }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { +func (i *{{ $.StructDeclaration.Name }}) {{ .Name }}(ctx context.Context, info {{ $.ServicePkg }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[{{ .Name }}] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/templates/example_service_init.go.tpl b/codegen/service/templates/example_service_init.go.tpl index 0e9a9fca28..fe043cc951 100644 --- a/codegen/service/templates/example_service_init.go.tpl +++ b/codegen/service/templates/example_service_init.go.tpl @@ -1,4 +1,4 @@ {{ printf "New%s returns the %s service implementation." .StructName .Name | comment }} -func New{{ .StructName }}() {{ .PkgName }}.Service { - return &{{ .VarName }}srvc{} +func {{ .ExampleConstructorDeclaration.Name }}() {{ .ServicePkg }}.{{ .ServiceDeclaration.Name }} { + return &{{ .ExampleStructDeclaration.Name }}{} } diff --git a/codegen/service/templates/example_service_struct.go.tpl b/codegen/service/templates/example_service_struct.go.tpl index baefc91ddb..1086752e5d 100644 --- a/codegen/service/templates/example_service_struct.go.tpl +++ b/codegen/service/templates/example_service_struct.go.tpl @@ -1,2 +1,2 @@ {{ printf "%s service example implementation.\nThe example methods log the requests and return zero values." .Name | comment }} -type {{ .VarName }}srvc struct {} +type {{ .ExampleStructDeclaration.Name }} struct {} diff --git a/codegen/service/templates/interceptors.go.tpl b/codegen/service/templates/interceptors.go.tpl index 8c53dbb773..18b01d2803 100644 --- a/codegen/service/templates/interceptors.go.tpl +++ b/codegen/service/templates/interceptors.go.tpl @@ -1,161 +1,106 @@ -// Public accessor methods for Info types +// Methods that provide information about each service call {{- range . }} + {{- $interceptor := . }} + {{- range .Methods }} -// Service returns the name of the service handling the request. -func (info *{{ .Name }}Info) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *{{ .InfoDeclaration.Name }}) Service() string { + return "{{ $interceptor.Service }}" } -// Method returns the name of the method handling the request. -func (info *{{ .Name }}Info) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *{{ .InfoDeclaration.Name }}) Method() string { + return "{{ .MethodName }}" } -// CallType returns the type of call the interceptor is handling. -func (info *{{ .Name }}Info) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *{{ .InfoDeclaration.Name }}) RawPayload() any { + return info.rawPayload } + {{- if .ServerUnaryInfoDeclaration }} -// RawPayload returns the raw payload of the request. -func (info *{{ .Name }}Info) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *{{ .ServerUnaryInfoDeclaration.Name }}) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } - {{- if .HasPayloadAccess }} - -// Payload returns a type-safe accessor for the method payload. -func (info *{{ .Name }}Info) Payload() {{ .Name }}Payload { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - {{- if hasEndpointStruct . }} - switch pay := info.RawPayload().(type) { - case *{{ .ServerStream.EndpointStruct }}: - return &{{ .PayloadAccess }}{payload: pay.Payload} - default: - return &{{ .PayloadAccess }}{payload: pay.({{ .PayloadRef }})} - } - {{- else }} - return &{{ .PayloadAccess }}{payload: info.RawPayload().({{ .PayloadRef }})} - {{- end }} - {{- end }} - default: - return nil - } - {{- else }} - {{- if hasEndpointStruct (index .Methods 0) }} - switch pay := info.RawPayload().(type) { - case *{{ (index .Methods 0).ServerStream.EndpointStruct }}: - return &{{ (index .Methods 0).PayloadAccess }}{payload: pay.Payload} - default: - return &{{ (index .Methods 0).PayloadAccess }}{payload: pay.({{ (index .Methods 0).PayloadRef }})} - } - {{- else }} - return &{{ (index .Methods 0).PayloadAccess }}{payload: info.RawPayload().({{ (index .Methods 0).PayloadRef }})} - {{- end }} {{- end }} -} - {{- end }} + {{- if .ClientUnaryInfoDeclaration }} - {{- if .HasResultAccess }} -// Result returns a type-safe accessor for the method result. -func (info *{{ .Name }}Info) Result(res any) {{ .Name }}Result { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .ResultAccess }}{result: res.({{ .ResultRef }})} - {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).ResultAccess }}{result: res.({{ (index .Methods 0).ResultRef }})} +// CallType reports that this is a client endpoint call. +func (info *{{ .ClientUnaryInfoDeclaration.Name }}) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} {{- end }} + {{- if .StreamingSendInfoDeclaration }} + +// CallType reports that this is a stream send. +func (info *{{ .StreamingSendInfoDeclaration.Name }}) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend } - {{- end }} + {{- end }} + {{- if .StreamingRecvInfoDeclaration }} - {{- if .HasStreamingPayloadAccess }} -// ClientStreamingPayload returns a type-safe accessor for the method streaming payload for a client-side interceptor. -func (info *{{ .Name }}Info) ClientStreamingPayload() {{ .Name }}StreamingPayload { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .StreamingPayloadAccess }}{payload: info.RawPayload().({{ .StreamingPayloadRef }})} - {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).StreamingPayloadAccess }}{payload: info.RawPayload().({{ (index .Methods 0).StreamingPayloadRef }})} +// CallType reports that this is a stream receive. +func (info *{{ .StreamingRecvInfoDeclaration.Name }}) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv +} {{- end }} + {{- if $interceptor.HasPayloadAccess }} + +// Payload returns this method's payload fields. +func (info *{{ .InfoDeclaration.Name }}) Payload() {{ $interceptor.PayloadDeclaration.Name }} { + return &{{ .PayloadAccessDeclaration.Name }}{payload: info.rawPayload.({{ .PayloadRef }})} } - {{- end }} + {{- if and .ServerUnaryInfoDeclaration (hasEndpointStruct .) }} - {{- if .HasStreamingResultAccess }} -// ClientStreamingResult returns a type-safe accessor for the method streaming result for a client-side interceptor. -func (info *{{ .Name }}Info) ClientStreamingResult(res any) {{ .Name }}StreamingResult { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .StreamingResultAccess }}{result: res.({{ .StreamingResultRef }})} +// Payload returns this server method's payload fields. +func (info *{{ .ServerUnaryInfoDeclaration.Name }}) Payload() {{ $interceptor.PayloadDeclaration.Name }} { + return &{{ .PayloadAccessDeclaration.Name }}{payload: info.rawPayload.(*{{ .ServerStream.EndpointStruct }}).Payload} +} {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).StreamingResultAccess }}{result: res.({{ (index .Methods 0).StreamingResultRef }})} {{- end }} -} - {{- end }} + {{- if $interceptor.HasResultAccess }} - {{- if .HasStreamingPayloadAccess }} -// ServerStreamingPayload returns a type-safe accessor for the method streaming payload for a server-side interceptor. -func (info *{{ .Name }}Info) ServerStreamingPayload(pay any) {{ .Name }}StreamingPayload { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .StreamingPayloadAccess }}{payload: pay.({{ .StreamingPayloadRef }})} - {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).StreamingPayloadAccess }}{payload: pay.({{ (index .Methods 0).StreamingPayloadRef }})} +// Result returns this method's result fields. +func (info *{{ .InfoDeclaration.Name }}) Result(res any) {{ $interceptor.ResultDeclaration.Name }} { + return &{{ .ResultAccessDeclaration.Name }}{result: res.({{ .ResultRef }})} +} {{- end }} + {{- if $interceptor.HasStreamingPayloadAccess }} + +// ClientStreamingPayload returns this method's outgoing streaming payload fields. +func (info *{{ .InfoDeclaration.Name }}) ClientStreamingPayload() {{ $interceptor.StreamingPayloadDeclaration.Name }} { + return &{{ .StreamingPayloadAccessDeclaration.Name }}{payload: info.rawPayload.({{ .StreamingPayloadRef }})} } - {{- end }} - {{- if .HasStreamingResultAccess }} -// ServerStreamingResult returns a type-safe accessor for the method streaming result for a server-side interceptor. -func (info *{{ .Name }}Info) ServerStreamingResult() {{ .Name }}StreamingResult { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .StreamingResultAccess }}{result: info.RawPayload().({{ .StreamingResultRef }})} - {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).StreamingResultAccess }}{result: info.RawPayload().({{ (index .Methods 0).StreamingResultRef }})} +// ServerStreamingPayload returns this method's incoming streaming payload fields. +func (info *{{ .InfoDeclaration.Name }}) ServerStreamingPayload(payload any) {{ $interceptor.StreamingPayloadDeclaration.Name }} { + return &{{ .StreamingPayloadAccessDeclaration.Name }}{payload: payload.({{ .StreamingPayloadRef }})} +} {{- end }} + {{- if $interceptor.HasStreamingResultAccess }} + +// ClientStreamingResult returns this method's incoming streaming result fields. +func (info *{{ .InfoDeclaration.Name }}) ClientStreamingResult(result any) {{ $interceptor.StreamingResultDeclaration.Name }} { + return &{{ .StreamingResultAccessDeclaration.Name }}{result: result.({{ .StreamingResultRef }})} +} + +// ServerStreamingResult returns this method's outgoing streaming result fields. +func (info *{{ .InfoDeclaration.Name }}) ServerStreamingResult() {{ $interceptor.StreamingResultDeclaration.Name }} { + return &{{ .StreamingResultAccessDeclaration.Name }}{result: info.rawPayload.({{ .StreamingResultRef }})} } + {{- end }} {{- end }} {{- end }} -{{- if hasPrivateImplementationTypes . }} -// Private implementation methods +{{- if hasPrivateAccessorMethods . }} +// Methods that read and write the selected payload and result fields {{- range . }} {{ $interceptor := . }} {{- range .Methods }} {{- $method := . }} {{- range $interceptor.ReadPayload }} -func (p *{{ $method.PayloadAccess }}) {{ .Name }}() {{ .TypeRef }} { +func (p *{{ $method.PayloadAccessDeclaration.Name }}) {{ .Name }}() {{ .TypeRef }} { {{- if .Pointer }} if p.payload.{{ .Name }} == nil { var zero {{ .TypeRef }} @@ -169,7 +114,7 @@ func (p *{{ $method.PayloadAccess }}) {{ .Name }}() {{ .TypeRef }} { {{- end }} {{- range $interceptor.WritePayload }} -func (p *{{ $method.PayloadAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { +func (p *{{ $method.PayloadAccessDeclaration.Name }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- if .Pointer }} p.payload.{{ .Name }} = &v {{- else }} @@ -179,7 +124,7 @@ func (p *{{ $method.PayloadAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- end }} {{- range $interceptor.ReadResult }} -func (r *{{ $method.ResultAccess }}) {{ .Name }}() {{ .TypeRef }} { +func (r *{{ $method.ResultAccessDeclaration.Name }}) {{ .Name }}() {{ .TypeRef }} { {{- if .Pointer }} if r.result.{{ .Name }} == nil { var zero {{ .TypeRef }} @@ -193,7 +138,7 @@ func (r *{{ $method.ResultAccess }}) {{ .Name }}() {{ .TypeRef }} { {{- end }} {{- range $interceptor.WriteResult }} -func (r *{{ $method.ResultAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { +func (r *{{ $method.ResultAccessDeclaration.Name }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- if .Pointer }} r.result.{{ .Name }} = &v {{- else }} @@ -203,7 +148,7 @@ func (r *{{ $method.ResultAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- end }} {{- range $interceptor.ReadStreamingPayload }} -func (p *{{ $method.StreamingPayloadAccess }}) {{ .Name }}() {{ .TypeRef }} { +func (p *{{ $method.StreamingPayloadAccessDeclaration.Name }}) {{ .Name }}() {{ .TypeRef }} { {{- if .Pointer }} if p.payload.{{ .Name }} == nil { var zero {{ .TypeRef }} @@ -217,7 +162,7 @@ func (p *{{ $method.StreamingPayloadAccess }}) {{ .Name }}() {{ .TypeRef }} { {{- end }} {{- range $interceptor.WriteStreamingPayload }} -func (p *{{ $method.StreamingPayloadAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { +func (p *{{ $method.StreamingPayloadAccessDeclaration.Name }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- if .Pointer }} p.payload.{{ .Name }} = &v {{- else }} @@ -227,7 +172,7 @@ func (p *{{ $method.StreamingPayloadAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) {{- end }} {{- range $interceptor.ReadStreamingResult }} -func (r *{{ $method.StreamingResultAccess }}) {{ .Name }}() {{ .TypeRef }} { +func (r *{{ $method.StreamingResultAccessDeclaration.Name }}) {{ .Name }}() {{ .TypeRef }} { {{- if .Pointer }} if r.result.{{ .Name }} == nil { var zero {{ .TypeRef }} @@ -241,7 +186,7 @@ func (r *{{ $method.StreamingResultAccess }}) {{ .Name }}() {{ .TypeRef }} { {{- end }} {{- range $interceptor.WriteStreamingResult }} -func (r *{{ $method.StreamingResultAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { +func (r *{{ $method.StreamingResultAccessDeclaration.Name }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- if .Pointer }} r.result.{{ .Name }} = &v {{- else }} diff --git a/codegen/service/templates/interceptors_types.go.tpl b/codegen/service/templates/interceptors_types.go.tpl index 10b890f470..1fd34d8f43 100644 --- a/codegen/service/templates/interceptors_types.go.tpl +++ b/codegen/service/templates/interceptors_types.go.tpl @@ -2,20 +2,43 @@ // Access interfaces for interceptor payloads and results type ( {{- range . }} - // {{ .Name }}Info provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - {{ .Name }}Info struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // {{ .InfoDeclaration.Name }} describes the service call currently passed to the interceptor. + {{ .InfoDeclaration.Name }} interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + {{- if .HasPayloadAccess }} + // Payload returns the selected fields from the method payload. + Payload() {{ .PayloadDeclaration.Name }} + {{- end }} + {{- if .HasResultAccess }} + // Result returns the selected fields from the method result. + Result(any) {{ .ResultDeclaration.Name }} + {{- end }} + {{- if .HasStreamingPayloadAccess }} + // ClientStreamingPayload returns selected fields from the outgoing stream payload. + ClientStreamingPayload() {{ .StreamingPayloadDeclaration.Name }} + // ServerStreamingPayload returns selected fields from the incoming stream payload. + ServerStreamingPayload(any) {{ .StreamingPayloadDeclaration.Name }} + {{- end }} + {{- if .HasStreamingResultAccess }} + // ClientStreamingResult returns selected fields from the incoming stream result. + ClientStreamingResult(any) {{ .StreamingResultDeclaration.Name }} + // ServerStreamingResult returns selected fields from the outgoing stream result. + ServerStreamingResult() {{ .StreamingResultDeclaration.Name }} + {{- end }} } {{- if .HasPayloadAccess }} - // {{ .Name }}Payload provides type-safe access to the method payload. + // {{ .PayloadDeclaration.Name }} provides type-safe access to the method payload. // It allows reading and writing specific fields of the payload as defined // in the design. - {{ .Name }}Payload interface { + {{ .PayloadDeclaration.Name }} interface { {{- range .ReadPayload }} {{ .Name }}() {{ .TypeRef }} {{- end }} @@ -26,10 +49,10 @@ type ( {{- end }} {{- if .HasResultAccess }} - // {{ .Name }}Result provides type-safe access to the method result. + // {{ .ResultDeclaration.Name }} provides type-safe access to the method result. // It allows reading and writing specific fields of the result as defined // in the design. - {{ .Name }}Result interface { + {{ .ResultDeclaration.Name }} interface { {{- range .ReadResult }} {{ .Name }}() {{ .TypeRef }} {{- end }} @@ -40,10 +63,10 @@ type ( {{- end }} {{- if .HasStreamingPayloadAccess }} - // {{ .Name }}StreamingPayload provides type-safe access to the method streaming payload. + // {{ .StreamingPayloadDeclaration.Name }} provides type-safe access to the method streaming payload. // It allows reading and writing specific fields of the streaming payload as defined // in the design. - {{ .Name }}StreamingPayload interface { + {{ .StreamingPayloadDeclaration.Name }} interface { {{- range .ReadStreamingPayload }} {{ .Name }}() {{ .TypeRef }} {{- end }} @@ -54,10 +77,10 @@ type ( {{- end }} {{- if .HasStreamingResultAccess }} - // {{ .Name }}StreamingResult provides type-safe access to the method streaming result. + // {{ .StreamingResultDeclaration.Name }} provides type-safe access to the method streaming result. // It allows reading and writing specific fields of the streaming result as defined // in the design. - {{ .Name }}StreamingResult interface { + {{ .StreamingResultDeclaration.Name }} interface { {{- range .ReadStreamingResult }} {{ .Name }}() {{ .TypeRef }} {{- end }} @@ -70,12 +93,40 @@ type ( ) {{- if hasPrivateImplementationTypes . }} -// Private implementation types +// Types used to provide information about each service call type ( {{- range . }} {{- range .Methods }} - {{- if .PayloadAccess }} - {{ .PayloadAccess }} struct { + {{ .InfoDeclaration.Name }} struct { + rawPayload any + } + {{- if .ServerUnaryInfoDeclaration }} + {{ .ServerUnaryInfoDeclaration.Name }} struct { + *{{ .InfoDeclaration.Name }} + } + {{- end }} + {{- if .ClientUnaryInfoDeclaration }} + {{ .ClientUnaryInfoDeclaration.Name }} struct { + *{{ .InfoDeclaration.Name }} + } + {{- end }} + {{- if .StreamingSendInfoDeclaration }} + {{ .StreamingSendInfoDeclaration.Name }} struct { + *{{ .InfoDeclaration.Name }} + } + {{- end }} + {{- if .StreamingRecvInfoDeclaration }} + {{ .StreamingRecvInfoDeclaration.Name }} struct { + *{{ .InfoDeclaration.Name }} + } + {{- end }} + {{- end }} + {{- end }} + + {{- range . }} + {{- range .Methods }} + {{- if .PayloadAccessDeclaration }} + {{ .PayloadAccessDeclaration.Name }} struct { payload {{ .PayloadRef }} } {{- end }} @@ -84,8 +135,8 @@ type ( {{- range . }} {{- range .Methods }} - {{- if .ResultAccess }} - {{ .ResultAccess }} struct { + {{- if .ResultAccessDeclaration }} + {{ .ResultAccessDeclaration.Name }} struct { result {{ .ResultRef }} } {{- end }} @@ -94,8 +145,8 @@ type ( {{- range . }} {{- range .Methods }} - {{- if .StreamingPayloadAccess }} - {{ .StreamingPayloadAccess }} struct { + {{- if .StreamingPayloadAccessDeclaration }} + {{ .StreamingPayloadAccessDeclaration.Name }} struct { payload {{ .StreamingPayloadRef }} } {{- end }} @@ -104,8 +155,8 @@ type ( {{- range . }} {{- range .Methods }} - {{- if .StreamingResultAccess }} - {{ .StreamingResultAccess }} struct { + {{- if .StreamingResultAccessDeclaration }} + {{ .StreamingResultAccessDeclaration.Name }} struct { result {{ .StreamingResultRef }} } {{- end }} diff --git a/codegen/service/templates/jsonrpc_handle_stream.go.tpl b/codegen/service/templates/jsonrpc_handle_stream.go.tpl deleted file mode 100644 index 310a979a79..0000000000 --- a/codegen/service/templates/jsonrpc_handle_stream.go.tpl +++ /dev/null @@ -1,17 +0,0 @@ -// HandleStream manages a JSON-RPC WebSocket connection, enabling bidirectional -// communication between the server and client. It receives requests from the -// client, dispatches them to the appropriate service methods, and can send -// server-initiated messages back to the client as needed. -func (s *{{ .VarName }}srvc) HandleStream(ctx context.Context, stream {{ .PkgName }}.Stream) error { - log.Printf(ctx, "{{ .VarName }}.HandleStream") - - // Example: In a real implementation you might read from an event source - // and send notifications via stream.Send(ctx, event). This stub returns - // when the context is canceled. - select { - case <-ctx.Done(): - return ctx.Err() - default: - return nil - } -} diff --git a/codegen/service/templates/jsonrpc_streaming_endpoint.go.tpl b/codegen/service/templates/jsonrpc_streaming_endpoint.go.tpl index 868b063886..2ebabb6484 100644 --- a/codegen/service/templates/jsonrpc_streaming_endpoint.go.tpl +++ b/codegen/service/templates/jsonrpc_streaming_endpoint.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}err error) { +func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}err error) { {{- if and .Result .ResultIsStruct }} res = &{{ .ResultFullName }}{} {{- end }} @@ -10,4 +10,4 @@ func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .Pay {{- end }} log.Printf(ctx, "{{ .ServiceVarName }}.{{ .Name }}") return -} \ No newline at end of file +} diff --git a/codegen/service/templates/return_type_init.go.tpl b/codegen/service/templates/return_type_init.go.tpl index 28e7a51b9f..95d387deca 100644 --- a/codegen/service/templates/return_type_init.go.tpl +++ b/codegen/service/templates/return_type_init.go.tpl @@ -2,22 +2,22 @@ {{- if eq (len .Views) 1 }} {{- with (index .Views 0) }} {{- if $.ToViewed -}} - p := {{ $.InitName }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}) + p := {{ .ToProjected.Name }}({{ $.ArgVar }}) return {{ if not $.IsCollection }}&{{ end }}{{ $.TargetType }}{Projected: p, View: {{ printf "%q" .Name }} } {{- else -}} - return {{ $.InitName }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}.Projected) + return {{ .ToResult.Name }}({{ $.ArgVar }}.Projected) {{- end }} {{- end }} {{- else -}} - var {{ .ReturnVar }} {{ .ReturnTypeRef }} + {{ if .ToViewed }}{{ .ReturnVar }} := {{ if not .IsCollection }}&{{ end }}{{ .TargetType }}{View: view}{{ else }}var {{ .ReturnVar }} {{ .ReturnTypeRef }}{{ end }} switch {{ if .ToResult }}{{ .ArgVar }}.View{{ else }}view{{ end }} { {{- range .Views }} case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: {{- if $.ToViewed }} - p := {{ $.InitName }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}) + p := {{ .ToProjected.Name }}({{ $.ArgVar }}) {{ $.ReturnVar }} = {{ if not $.IsCollection }}&{{ end }}{{ $.TargetType }}{Projected: p, View: {{ printf "%q" .Name }} } {{- else }} - {{ $.ReturnVar }} = {{ $.InitName }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}.Projected) + {{ $.ReturnVar }} = {{ .ToResult.Name }}({{ $.ArgVar }}.Projected) {{- end }} {{- end }} } @@ -26,14 +26,14 @@ {{- else if .IsCollection -}} {{ .ReturnVar }} := make({{ .TargetType }}, len({{ .ArgVar }})) for i, n := range {{ .ArgVar }} { - {{ .ReturnVar }}[i] = {{ .InitName }}(n) + {{ .ReturnVar }}[i] = {{ .Init.Name }}(n) } return {{ .ReturnVar }} {{- else -}} {{ .Code }} {{- range .Fields }} if {{ $.Source }}.{{ .VarName }} != nil { - {{ $.Target }}.{{ .VarName }} = {{ .FieldInit }}({{ $.Source }}.{{ .VarName }}) + {{ $.Target }}.{{ .VarName }} = {{ .Declaration.Name }}({{ $.Source }}.{{ .VarName }}) } {{- end }} return {{ .ReturnVar }} diff --git a/codegen/service/templates/server_interceptor_stream_wrapper_types.go.tpl b/codegen/service/templates/server_interceptor_stream_wrapper_types.go.tpl index a33a45fa2a..15f94b74f3 100644 --- a/codegen/service/templates/server_interceptor_stream_wrapper_types.go.tpl +++ b/codegen/service/templates/server_interceptor_stream_wrapper_types.go.tpl @@ -1,7 +1,7 @@ {{- range .WrappedServerStreams }} -{{ comment (printf "wrapped%s is a server interceptor wrapper for the %s stream." .Interface .Interface) }} -type wrapped{{ .Interface }} struct { +{{ comment (printf "%s is a server interceptor wrapper for the %s stream." .WrapperDeclaration.Name .InterfaceDeclaration.Name) }} +type {{ .WrapperDeclaration.Name }} struct { ctx context.Context {{- if ne .SendTypeRef "" }} sendWithContext func(context.Context, {{ .SendTypeRef }}) error @@ -9,6 +9,6 @@ type wrapped{{ .Interface }} struct { {{- if ne .RecvTypeRef "" }} recvWithContext func(context.Context) ({{ .RecvTypeRef }}, error) {{- end }} - stream {{ .Interface }} + stream {{ .InterfaceDeclaration.Name }} } {{- end }} diff --git a/codegen/service/templates/server_interceptor_stream_wrappers.go.tpl b/codegen/service/templates/server_interceptor_stream_wrappers.go.tpl index 26454cd232..dcf2fd89d7 100644 --- a/codegen/service/templates/server_interceptor_stream_wrappers.go.tpl +++ b/codegen/service/templates/server_interceptor_stream_wrappers.go.tpl @@ -1,19 +1,19 @@ {{- range .WrappedServerStreams }} {{ comment (print "Unwrap returns the underlying stream type.") }} -func (w *wrapped{{ .Interface }}) Unwrap() any { +func (w *{{ .WrapperDeclaration.Name }}) Unwrap() any { return w.stream } {{- if ne .SendTypeRef "" }} -{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor." .SendName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { +{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor." .SendName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { return w.SendWithContext(w.ctx, v) } -{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor with context." .SendWithContextName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { +{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor with context." .SendWithContextName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { if w.sendWithContext == nil { return w.stream.{{ .SendWithContextName }}(ctx, v) } @@ -22,13 +22,13 @@ func (w *wrapped{{ .Interface }}) {{ .SendWithContextName }}(ctx context.Context {{- end }} {{- if ne .RecvTypeRef "" }} -{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor." .RecvName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { +{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor." .RecvName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { return w.RecvWithContext(w.ctx) } -{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor with context." .RecvWithContextName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { +{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor with context." .RecvWithContextName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { if w.recvWithContext == nil { return w.stream.{{ .RecvWithContextName }}(ctx) } @@ -38,7 +38,7 @@ func (w *wrapped{{ .Interface }}) {{ .RecvWithContextName }}(ctx context.Context {{- if .MustClose }} // Close closes the stream. -func (w *wrapped{{ .Interface }}) Close() error { +func (w *{{ .WrapperDeclaration.Name }}) Close() error { return w.stream.Close() } {{- end }} diff --git a/codegen/service/templates/server_interceptor_wrappers.go.tpl b/codegen/service/templates/server_interceptor_wrappers.go.tpl index 0dac75a6a7..0f0c481ac4 100644 --- a/codegen/service/templates/server_interceptor_wrappers.go.tpl +++ b/codegen/service/templates/server_interceptor_wrappers.go.tpl @@ -1,21 +1,18 @@ -{{- range .ServerInterceptors }} +{{- range .Interceptors }} {{- $interceptor := . }} {{- range .Methods }} -{{ comment (printf "wrap%s%s applies the %s server interceptor to endpoints." $interceptor.Name .MethodName $interceptor.DesignName) }} -func wrap{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { +{{ comment (printf "%s applies the %s server interceptor to endpoints." .ServerWrapperDeclaration.Name $interceptor.DesignName) }} +func {{ .ServerWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.InterceptorsDeclaration.Name }}) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { {{- if or $interceptor.HasStreamingPayloadAccess $interceptor.HasStreamingResultAccess }} stream := req.(*{{ .ServerStream.EndpointStruct }}).Stream - req.(*{{ .ServerStream.EndpointStruct }}).Stream = &wrapped{{ .ServerStream.Interface }}{ + req.(*{{ .ServerStream.EndpointStruct }}).Stream = &{{ .ServerStream.WrapperDeclaration.Name }}{ ctx: ctx, {{- if $interceptor.HasStreamingResultAccess }} sendWithContext: func(ctx context.Context, req {{ .ServerStream.SendTypeRef }}) error { - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &{{ .StreamingSendInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{rawPayload: req}, } _, err := i.{{ $interceptor.Name }}(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.({{ .ServerStream.SendTypeRef }}) @@ -26,10 +23,8 @@ func wrap{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i Serve {{- end }} {{- if $interceptor.HasStreamingPayloadAccess }} recvWithContext: func(ctx context.Context) ({{ .ServerStream.RecvTypeRef }}, error) { - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorStreamingRecv, + info := &{{ .StreamingRecvInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{}, } res, err := i.{{ $interceptor.Name }}(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.{{ .ServerStream.RecvWithContextName }}(ctx) @@ -41,22 +36,16 @@ func wrap{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i Serve stream: stream, } {{- if $interceptor.HasPayloadAccess }} - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &{{ .ServerUnaryInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{rawPayload: req}, } return i.{{ $interceptor.Name }}(ctx, info, endpoint) {{- else }} return endpoint(ctx, req) {{- end }} {{- else }} - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &{{ .ServerUnaryInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: &{{ .InfoDeclaration.Name }}{rawPayload: req}, } return i.{{ $interceptor.Name }}(ctx, info, endpoint) {{- end }} diff --git a/codegen/service/templates/server_interceptors.go.tpl b/codegen/service/templates/server_interceptors.go.tpl index 3bd263dc95..694b1b83b3 100644 --- a/codegen/service/templates/server_interceptors.go.tpl +++ b/codegen/service/templates/server_interceptors.go.tpl @@ -2,11 +2,11 @@ // Server interceptors execute after the request is decoded and before the // payload is sent to the service. The implementation is responsible for calling // next to complete the request. -type ServerInterceptors interface { +type {{ .ServerInterceptorsDeclaration.Name }} interface { {{- range .ServerInterceptors }} {{- if .Description }} {{ comment .Description }} {{- end }} - {{ .Name }}(ctx context.Context, info *{{ .Name }}Info, next goa.Endpoint) (any, error) + {{ .Name }}(ctx context.Context, info {{ .InfoDeclaration.Name }}, next goa.Endpoint) (any, error) {{- end }} } diff --git a/codegen/service/templates/service.go.tpl b/codegen/service/templates/service.go.tpl index 61626354f1..7a71c78497 100644 --- a/codegen/service/templates/service.go.tpl +++ b/codegen/service/templates/service.go.tpl @@ -1,10 +1,6 @@ {{ comment .Description }} -type Service interface { -{{- if isJSONRPCWebSocket . }} - {{ comment "HandleStream handles the JSON-RPC WebSocket streaming connection. Calling Recv() on the stream will dispatch requests to the appropriate methods below." }} - HandleStream(context.Context, Stream) error -{{- end }} +type {{ .ServiceDeclaration.Name }} interface { {{- range .Methods }} {{ comment .Description }} {{- if .SkipResponseBodyEncodeDecode }} @@ -23,19 +19,12 @@ type Service interface { {{- end }} {{- end }} {{- if .ServerStream }} - {{- if and .IsJSONRPC (not .IsJSONRPCSSE) (eq .ServerStream.Kind 2) }} - {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}) ({{ if .Result }}res {{ .ResultRef }}, {{ end }}err error) - {{- else if .HasMixedResults }} - {{- /* Mixed results: the method may be invoked in a unary (JSON) or streaming (SSE) mode. - The server stream is non-nil only when the transport negotiates streaming. */}} + {{- if .HasMixedResults }} + {{- /* Mixed results always receive a server stream. Ordinary HTTP supplies + one that discards sent values, while SSE sends them to the client. */}} {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}, {{ .ServerStream.Interface }}) ({{ if .Result }}res {{ .ResultRef }}, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) {{- else }} - {{- if and .IsJSONRPC (not .IsJSONRPCSSE) (eq .ServerStream.Kind 3) .PayloadRef }} - {{- /* JSON-RPC WebSocket server streaming with non-streaming payload */ -}} - {{ .VarName }}(context.Context, {{ .PayloadRef }}, {{ .ServerStream.Interface }}) (err error) - {{- else }} - {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}, {{ .ServerStream.Interface }}) (err error) - {{- end }} + {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}, {{ .ServerStream.Interface }}) (err error) {{- end }} {{- else }} {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}{{ if .SkipRequestBodyEncodeDecode }}, io.ReadCloser{{ end }}) ({{ if .Result }}res {{ .ResultRef }}, {{ end }}{{ if .SkipResponseBodyEncodeDecode }}body io.ReadCloser, {{ end }}{{ if .Result }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}{{ end }}err error) @@ -45,7 +34,7 @@ type Service interface { {{- if .Schemes }} // Auther defines the authorization functions to be implemented by the service. -type Auther interface { +type {{ .AutherDeclaration.Name }} interface { {{- range .Schemes.DedupeByType }} {{ printf "%sAuth implements the authorization logic for the %s security scheme." .Type .Type | comment }} {{ .Type }}Auth(ctx context.Context, {{ if eq .Type "Basic" }}user, pass{{ else if eq .Type "APIKey" }}key{{ else }}token{{ end }} string, schema *security.{{ .Type }}Scheme) (context.Context, error) @@ -54,100 +43,47 @@ type Auther interface { {{- end }} // APIName is the name of the API as defined in the design. -const APIName = {{ printf "%q" .APIName }} +const {{ .APINameDeclaration.Name }} = {{ printf "%q" .APIName }} // APIVersion is the version of the API as defined in the design. -const APIVersion = {{ printf "%q" .APIVersion }} +const {{ .APIVersionDeclaration.Name }} = {{ printf "%q" .APIVersion }} // 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 = {{ printf "%q" .Name }} +const {{ .ServiceNameDeclaration.Name }} = {{ printf "%q" .Name }} // 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 = [{{ len .Methods }}]string{ {{ range .Methods }}{{ printf "%q" .Name }}, {{ end }} } +var {{ .MethodNamesDeclaration.Name }} = [{{ len .Methods }}]string{ {{ range .Methods }}{{ printf "%q" .Name }}, {{ end }} } {{- range .Methods }} {{- if .ServerStream }} {{ template "stream_interface" (streamInterfaceFor "server" . .ServerStream) }} - {{- /* Emit client stream interface */ -}} - {{- if .IsJSONRPC }} - {{- if .ClientStream }} - {{ template "stream_interface" (streamInterfaceFor "client" . .ClientStream) }} - {{- end }} - {{- else }} {{ template "stream_interface" (streamInterfaceFor "client" . .ClientStream) }} - {{- end }} - {{- end }} -{{- end }} - -{{- if hasJSONRPCStreaming . }} - {{- if isJSONRPCWebSocket . }} - {{ template "jsonrpc_websocket_stream" . }} - {{- else }} - {{ template "jsonrpc_sse_stream" . }} {{- end }} {{- end }} {{- define "stream_interface" }} -{{- if and .IsJSONRPCSSE (eq .Type "server") }} -{{ printf "%sEvent is the interface implemented by the result type for the %s method." .MethodVarName .Endpoint | comment }} -type {{ .MethodVarName }}Event interface { - is{{ .MethodVarName }}Event() -} - -{{ printf "is%sEvent implements the %sEvent interface." .MethodVarName .MethodVarName | comment }} -func ({{ .Stream.SendTypeRef }}) is{{ .MethodVarName }}Event() {} - -{{ printf "%s allows streaming instances of %s over SSE." .Stream.Interface .Stream.SendTypeRef | comment }} -type {{ .Stream.Interface }} interface { - {{- if .Stream.SendTypeRef }} - {{ comment .Stream.SendDesc }} - {{ comment "IMPORTANT: Send only sends JSON-RPC notifications. Use SendAndClose to send a final response." }} - Send(ctx context.Context, event {{ .MethodVarName }}Event) error - {{- if .Stream.SendAndCloseName }} - {{ comment .Stream.SendAndCloseDesc }} - {{ comment "The result will be sent as a JSON-RPC response with the original request ID." }} - {{ comment "If the result has an ID field populated, that ID will be used instead of the request ID." }} - {{ .Stream.SendAndCloseName }}(ctx context.Context, event {{ .MethodVarName }}Event) error - {{- end }} - {{- end }} - {{ comment "SendError sends a JSON-RPC error response." }} - SendError(ctx context.Context, id string, err error) error -} -{{- else }} {{- $elemType := .Stream.SendTypeRef -}} {{- if not $elemType }}{{- $elemType = .Stream.RecvTypeRef }}{{- end }} {{ printf "%s allows streaming instances of %s to the client." .Stream.Interface $elemType | comment }} type {{ .Stream.Interface }} interface { {{- if .Stream.SendTypeRef }} - {{- if .IsJSONRPCWebSocket }} - {{ comment "SendNotification sends a JSON-RPC notification (no response expected)." }} - SendNotification(context.Context, {{ .Stream.SendTypeRef }}) error - {{ comment "SendResponse sends a JSON-RPC response with the original request ID." }} - SendResponse(context.Context, {{ .Stream.SendTypeRef }}) error - {{ comment "SendError sends a JSON-RPC error response." }} - SendError(context.Context, error) error - {{- else }} {{ comment .Stream.SendDesc }} {{ .Stream.SendName }}({{ .Stream.SendTypeRef }}) error {{ comment .Stream.SendWithContextDesc }} {{ .Stream.SendWithContextName }}(context.Context, {{ .Stream.SendTypeRef }}) error - {{- end }} {{- end }} - {{- if and .Stream.RecvTypeRef (not .IsJSONRPCWebSocket) }} + {{- if .Stream.RecvTypeRef }} {{ comment .Stream.RecvDesc }} {{ .Stream.RecvName }}() ({{ .Stream.RecvTypeRef }}, error) {{ comment .Stream.RecvWithContextDesc }} {{ .Stream.RecvWithContextName }}(context.Context) ({{ .Stream.RecvTypeRef }}, error) {{- end }} - {{- if .IsJSONRPCWebSocket }} - {{ comment "Close closes the stream." }} - Close() error - {{- else if .Stream.MustClose }} + {{- if .Stream.MustClose }} {{ comment "Close closes the stream." }} Close() error {{- end }} @@ -158,71 +94,3 @@ type {{ .Stream.Interface }} interface { {{- end }} } {{- end }} -{{- end }} - -{{- define "jsonrpc_websocket_stream" }} -{{ printf "Stream defines the interface for managing a WebSocket streaming connection in the %s server. It allows sending results, sending errors, receiving requests, and closing the connection. This interface is used by the service to interact with clients over WebSocket using JSON-RPC." .Name | comment }} -type Stream interface { -{{- range .Methods }} - {{- if .Result }} - {{ printf "Send%sNotification sends a JSON-RPC notification for the %s method (no response expected)." .VarName .Name | comment }} - Send{{ .VarName }}Notification(ctx context.Context, result {{ .ResultRef }}) error - {{ printf "Send%sResponse sends a JSON-RPC response for the %s method with the given ID." .VarName .Name | comment }} - Send{{ .VarName }}Response(ctx context.Context, id any, result {{ .ResultRef }}) error - {{- end }} -{{- end }} - {{ comment "SendError sends a JSON-RPC error response." }} - SendError(ctx context.Context, id any, err error) error - {{ printf "Recv reads JSON-RPC requests from the %s service WebSocket stream and dispatches them to the appropriate method." .Name | comment }} - Recv(ctx context.Context) error - {{ comment "Close closes the stream." }} - Close() error -} -{{- end }} - -{{- define "jsonrpc_sse_stream" }} -{{- $hasResults := false }} -{{- $hasErrors := false }} -{{- $resultTypes := "" }} -{{- range (dedupeByResult .Methods) }} - {{- if .Result }} - {{- $hasResults = true }} - {{- if $resultTypes }} - {{- $resultTypes = printf "%s, %s" $resultTypes .ResultRef }} - {{- else }} - {{- $resultTypes = .ResultRef }} - {{- end }} - {{- end }} -{{- end }} -{{- range .Methods }} - {{- if .Errors }}{{ $hasErrors = true }}{{ end }} -{{- end }} -{{ printf "Stream defines the interface for managing an SSE streaming connection in the %s server. It allows sending notifications and final responses. This interface is used by the service to interact with clients over SSE using JSON-RPC." .Name | comment }} -type Stream interface { -{{- if $hasResults }} - {{ comment "Send sends an event (notification or response) to the client." }} - {{ comment "For notifications, the result should not have an ID field." }} - {{ comment "For responses, the result must have an ID field." }} - {{ printf "Accepted types: %s" $resultTypes | comment }} - Send(ctx context.Context, event Event) error -{{- end }} -{{- if $hasErrors }} - {{ comment "SendError sends a JSON-RPC error response." }} - SendError(ctx context.Context, id string, err error) error -{{- end }} -} - -{{- if $hasResults }} -{{ printf "Event is the interface implemented by all result types that can be sent via the %s Stream." .Name | comment }} -type Event interface { - is{{ .VarName }}Event() -} - - {{- range (dedupeByResult .Methods) }} - {{- if .Result }} -{{ printf "is%sEvent implements the Event interface." $.VarName | comment }} -func ({{ .ResultRef }}) is{{ $.VarName }}Event() {} - {{- end }} - {{- end }} -{{- end }} -{{- end }} diff --git a/codegen/service/templates/service_client.go.tpl b/codegen/service/templates/service_client.go.tpl index 90828cc6a7..6a06c234cc 100644 --- a/codegen/service/templates/service_client.go.tpl +++ b/codegen/service/templates/service_client.go.tpl @@ -1,5 +1,5 @@ -// {{ .ClientVarName }} is the {{ printf "%q" .Name }} service client. -type {{ .ClientVarName }} struct { +// {{ .ClientDeclaration.Name }} is the {{ printf "%q" .Name }} service client. +type {{ .ClientDeclaration.Name }} struct { {{- range .Methods}} {{ .EndpointField }} goa.Endpoint {{- if .HasMixedResults }} diff --git a/codegen/service/templates/service_client_init.go.tpl b/codegen/service/templates/service_client_init.go.tpl index 548b288232..cac6bc4de6 100644 --- a/codegen/service/templates/service_client_init.go.tpl +++ b/codegen/service/templates/service_client_init.go.tpl @@ -1,10 +1,10 @@ -{{ printf "New%s initializes a %q service client given the endpoints." .ClientVarName .Name | comment }} -func New{{ .ClientVarName }}({{ if .ClientInitArgs }}{{ .ClientInitArgs }} goa.Endpoint{{ if .HasClientInterceptors }}, ci ClientInterceptors{{ end }}{{ else }}{{ if .HasClientInterceptors }}ci ClientInterceptors{{ end }}{{ end }}) *{{ .ClientVarName }} { - return &{{ .ClientVarName }}{ +{{ printf "%s initializes a %q service client given the endpoints." .NewClientDeclaration.Name .Name | comment }} +func {{ .NewClientDeclaration.Name }}({{ if .ClientInitArgs }}{{ .ClientInitArgs }} goa.Endpoint{{ if .HasClientInterceptors }}, ci {{ .ClientInterceptorsDeclaration.Name }}{{ end }}{{ else }}{{ if .HasClientInterceptors }}ci {{ .ClientInterceptorsDeclaration.Name }}{{ end }}{{ end }}) *{{ .ClientDeclaration.Name }} { + return &{{ .ClientDeclaration.Name }}{ {{- range .Methods }} - {{ .EndpointField }}: {{ if .ClientInterceptors }}Wrap{{ .VarName }}ClientEndpoint({{ end }}{{ .ArgName }}{{ if .ClientInterceptors }}, ci){{ end }}, + {{ .EndpointField }}: {{ if .ClientInterceptors }}{{ .ClientEndpointWrapperDeclaration.Name }}({{ end }}{{ .ArgName }}{{ if .ClientInterceptors }}, ci){{ end }}, {{- if .HasMixedResults }} - {{ .StreamEndpointField }}: {{ if .ClientInterceptors }}Wrap{{ .VarName }}ClientEndpoint({{ end }}{{ .StreamArgName }}{{ if .ClientInterceptors }}, ci){{ end }}, + {{ .StreamEndpointField }}: {{ if .ClientInterceptors }}{{ .ClientEndpointWrapperDeclaration.Name }}({{ end }}{{ .StreamArgName }}{{ if .ClientInterceptors }}, ci){{ end }}, {{- end }} {{- end }} } diff --git a/codegen/service/templates/service_client_method.go.tpl b/codegen/service/templates/service_client_method.go.tpl index df5c8f1bfd..e8099de478 100644 --- a/codegen/service/templates/service_client_method.go.tpl +++ b/codegen/service/templates/service_client_method.go.tpl @@ -9,7 +9,7 @@ {{- end }} {{- if .HasMixedResults }} {{- $unaryResultType := .ResultRef }} -func (c *{{ .ClientVarName }}) {{ .VarName }}(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) ({{ if $unaryResultType }}res {{ $unaryResultType }}, {{ end }}{{ if .MethodData.SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}err error) { +func (c *{{ .ClientDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) ({{ if $unaryResultType }}res {{ $unaryResultType }}, {{ end }}{{ if .MethodData.SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}err error) { {{- if or $unaryResultType .MethodData.SkipResponseBodyEncodeDecode }} var ires any {{- end }} @@ -30,7 +30,7 @@ func (c *{{ .ClientVarName }}) {{ .VarName }}(ctx context.Context{{ if .PayloadR } {{ printf "%sStream calls the %q endpoint of the %q service with server streaming enabled." .VarName .Name .ServiceName | comment }} -func (c *{{ .ClientVarName }}) {{ .VarName }}Stream(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) (res {{ .ClientStream.Interface }}, err error) { +func (c *{{ .ClientDeclaration.Name }}) {{ .VarName }}Stream(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) (res {{ .ClientStream.Interface }}, err error) { var ires any ires, err = c.{{ .StreamEndpointField }}(ctx, {{ if .MethodData.SkipRequestBodyEncodeDecode }}&{{ .RequestStruct }}{ {{ if .PayloadRef }}Payload: p, {{ end }}Body: req }{{ else if .PayloadRef }}p{{ else }}nil{{ end }}) if err != nil { @@ -44,7 +44,7 @@ func (c *{{ .ClientVarName }}) {{ .VarName }}Stream(ctx context.Context{{ if .Pa {{- /* When a client stream exists, always return it from the client method. */ -}} {{- $resultType = .ClientStream.Interface }} {{- end }} -func (c *{{ .ClientVarName }}) {{ .VarName }}(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) ({{ if $resultType }}res {{ $resultType }}, {{ end }}{{ if .MethodData.SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}err error) { +func (c *{{ .ClientDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) ({{ if $resultType }}res {{ $resultType }}, {{ end }}{{ if .MethodData.SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}err error) { {{- if or $resultType .MethodData.SkipResponseBodyEncodeDecode }} var ires any {{- end }} diff --git a/codegen/service/templates/service_endpoint_method.go.tpl b/codegen/service/templates/service_endpoint_method.go.tpl index e375fd4197..638ea18c98 100644 --- a/codegen/service/templates/service_endpoint_method.go.tpl +++ b/codegen/service/templates/service_endpoint_method.go.tpl @@ -1,7 +1,7 @@ -{{ printf "New%sEndpoint returns an endpoint function that calls the method %q of service %q." .VarName .Name .ServiceName | comment }} -func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeByType }}, auth{{ .Type }}Fn security.Auth{{ .Type }}Func{{ end }}) goa.Endpoint { +{{ printf "%s returns an endpoint function that calls the method %q of service %q." .EndpointDeclaration.Name .Name .ServiceName | comment }} +func {{ .EndpointDeclaration.Name }}(s {{ .ServiceDeclaration.Name }}{{ range .Schemes.DedupeByType }}, auth{{ .Type }}Fn security.Auth{{ .Type }}Func{{ end }}) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { {{- if .ServerStream }} {{- if .ServerStream.EndpointStruct }} @@ -121,38 +121,29 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy {{- if .ServerStream }} {{- if .ServerStream.EndpointStruct }} {{- if .HasMixedResults }} + {{- if .ResultRef }} res, {{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view, {{ end }}{{ end }}err := s.{{ .VarName }}(ctx, {{ if .PayloadRef }}{{ $payload }}, {{ end }}ep.Stream) if err != nil { return nil, err } {{- if .ViewedResult }} {{- if .ViewedResult.ViewName }} - vres := {{ $.ViewedResult.Init.Name }}(res, {{ printf "%q" .ViewedResult.ViewName }}) + vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, {{ printf "%q" .ViewedResult.ViewName }}) {{- else }} - vres := {{ $.ViewedResult.Init.Name }}(res, view) + vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, view) {{- end }} + if err := {{ .ViewedResult.ViewsPkg }}.{{ .ViewedResult.Validate.Declaration.Name }}(vres); err != nil { + return nil, err + } return vres, nil {{- else }} return res, nil {{- end }} - {{- else }} - return nil, s.{{ .VarName }}(ctx, {{ if .PayloadRef }}{{ $payload }}, {{ end }}ep.Stream) - {{- end }} - {{- else }} - {{- /* JSON-RPC WebSocket client streaming: no stream parameter, just payload */ -}} - {{- if .PayloadRef }} - p := req.({{ .PayloadRef }}) - {{- if .ResultRef }} - return s.{{ .VarName }}(ctx, p) {{- else }} - return nil, s.{{ .VarName }}(ctx, p) + return nil, s.{{ .VarName }}(ctx, {{ if .PayloadRef }}{{ $payload }}, {{ end }}ep.Stream) {{- end }} {{- else }} - {{- if .ResultRef }} - return s.{{ .VarName }}(ctx) - {{- else }} - return nil, s.{{ .VarName }}(ctx) - {{- end }} + return nil, s.{{ .VarName }}(ctx, {{ if .PayloadRef }}{{ $payload }}, {{ end }}ep.Stream) {{- end }} {{- end }} {{- else if .SkipRequestBodyEncodeDecode }} @@ -167,7 +158,10 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy if err != nil { return nil, err } - vres := {{ $.ViewedResult.Init.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) + vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) + if err := {{ .ViewedResult.ViewsPkg }}.{{ .ViewedResult.Validate.Declaration.Name }}(vres); err != nil { + return nil, err + } return vres, nil {{- else }} return {{ if not .ResultRef }}nil, {{ end }}s.{{ .VarName }}(ctx, {{ if .PayloadRef }}ep.Payload, {{ end }}ep.Body) @@ -177,7 +171,10 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy if err != nil { return nil, err } - vres := {{ $.ViewedResult.Init.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) + vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) + if err := {{ .ViewedResult.ViewsPkg }}.{{ .ViewedResult.Validate.Declaration.Name }}(vres); err != nil { + return nil, err + } return vres, nil {{- else if .SkipResponseBodyEncodeDecode }} {{ if .ResultRef }}res, {{ end }}body, err := s.{{ .VarName }}(ctx{{ if .PayloadRef }}, {{ $payload}}{{ end }}) diff --git a/codegen/service/templates/service_endpoint_stream_struct.go.tpl b/codegen/service/templates/service_endpoint_stream_struct.go.tpl index b26b32f855..04a4ae33e4 100644 --- a/codegen/service/templates/service_endpoint_stream_struct.go.tpl +++ b/codegen/service/templates/service_endpoint_stream_struct.go.tpl @@ -5,10 +5,6 @@ type {{ .ServerStream.EndpointStruct }} struct { {{- if .PayloadRef }} {{ comment "Payload is the method payload." }} Payload {{ .PayloadRef }} -{{- end }} -{{- if .IsJSONRPC }} - {{ comment "RequestID is the JSON-RPC request ID (available for JSON-RPC transports)." }} - RequestID any {{- end }} {{ printf "Stream is the server stream used by the %q method to send data." .Name | comment }} Stream {{ .ServerStream.Interface }} diff --git a/codegen/service/templates/service_endpoints.go.tpl b/codegen/service/templates/service_endpoints.go.tpl index 547d1242cd..7f46e17d82 100644 --- a/codegen/service/templates/service_endpoints.go.tpl +++ b/codegen/service/templates/service_endpoints.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -type {{ .VarName }} struct { +type {{ .EndpointsDeclaration.Name }} struct { {{- range .Methods}} {{ .VarName }} goa.Endpoint {{- end }} diff --git a/codegen/service/templates/service_endpoints_init.go.tpl b/codegen/service/templates/service_endpoints_init.go.tpl index 38770a22f7..d7e9e57d20 100644 --- a/codegen/service/templates/service_endpoints_init.go.tpl +++ b/codegen/service/templates/service_endpoints_init.go.tpl @@ -1,23 +1,23 @@ -{{ printf "New%s wraps the methods of the %q service with endpoints." .VarName .Name | comment }} -func New{{ .VarName }}(s {{ .ServiceVarName }}{{ if .HasServerInterceptors }}, si ServerInterceptors{{ end }}) *{{ .VarName }} { +{{ printf "%s wraps the methods of the %q service with endpoints." .NewEndpointsDeclaration.Name .Name | comment }} +func {{ .NewEndpointsDeclaration.Name }}(s {{ .ServiceDeclaration.Name }}{{ if .HasServerInterceptors }}, si {{ .ServerInterceptorsDeclaration.Name }}{{ end }}) *{{ .EndpointsDeclaration.Name }} { {{- if .Schemes }} // Casting service to Auther interface a := s.(Auther) {{- end }} {{- if .HasServerInterceptors }} - endpoints := &{{ .VarName }}{ + endpoints := &{{ .EndpointsDeclaration.Name }}{ {{- else }} - return &{{ .VarName }}{ + return &{{ .EndpointsDeclaration.Name }}{ {{- end }} {{- range .Methods }} - {{ .VarName }}: New{{ .VarName }}Endpoint(s{{ range .Schemes.DedupeByType }}, a.{{ .Type }}Auth{{ end }}), + {{ .VarName }}: {{ .EndpointDeclaration.Name }}(s{{ range .Schemes.DedupeByType }}, a.{{ .Type }}Auth{{ end }}), {{- end }} } {{- if .HasServerInterceptors }} {{- range .Methods }} {{- if .ServerInterceptors }} - endpoints.{{ .VarName }} = Wrap{{ .VarName }}Endpoint(endpoints.{{ .VarName }}, si) + endpoints.{{ .VarName }} = {{ .ServerEndpointWrapperDeclaration.Name }}(endpoints.{{ .VarName }}, si) {{- end }} {{- end }} return endpoints diff --git a/codegen/service/templates/service_endpoints_use.go.tpl b/codegen/service/templates/service_endpoints_use.go.tpl index 0539583d91..506cb85f7b 100644 --- a/codegen/service/templates/service_endpoints_use.go.tpl +++ b/codegen/service/templates/service_endpoints_use.go.tpl @@ -1,7 +1,7 @@ {{ printf "Use applies the given middleware to all the %q service endpoints." .Name | comment }} -func (e *{{ .VarName }}) Use(m func(goa.Endpoint) goa.Endpoint) { +func (e *{{ .EndpointsDeclaration.Name }}) Use(m func(goa.Endpoint) goa.Endpoint) { {{- range .Methods }} e.{{ .VarName }} = m(e.{{ .VarName }}) {{- end }} diff --git a/codegen/service/templates/transform_helper.go.tpl b/codegen/service/templates/transform_helper.go.tpl index 0e23fe9e0f..e23f97ba9e 100644 --- a/codegen/service/templates/transform_helper.go.tpl +++ b/codegen/service/templates/transform_helper.go.tpl @@ -1,5 +1,7 @@ -{{ printf "%s builds a value of type %s from a value of type %s." .Name .ResultTypeRef .ParamTypeRef | comment }} -func {{ .Name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { +{{- $name := .Name -}} +{{- if .Declaration -}}{{- $name = .Declaration.Name -}}{{- end }} +{{ printf "%s builds a value of type %s from a value of type %s." $name .ResultTypeRef .ParamTypeRef | comment }} +func {{ if .Declaration }}{{ .Declaration.Name }}{{ else }}{{ .Name }}{{ end }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { {{ .Code }} return res } diff --git a/codegen/service/templates/type_init.go.tpl b/codegen/service/templates/type_init.go.tpl index 9815875413..f372d47e2e 100644 --- a/codegen/service/templates/type_init.go.tpl +++ b/codegen/service/templates/type_init.go.tpl @@ -1,4 +1,4 @@ {{ comment .Description }} -func {{ .Name }}({{ range .Args }}{{ .Name }} {{ .Ref }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{ range .Args }}{{ .Name }} {{ .Ref }}, {{ end }}) {{ .ReturnTypeRef }} { {{ .Code }} } diff --git a/codegen/service/templates/type_validate.go.tpl b/codegen/service/templates/type_validate.go.tpl index 49ebacc867..93ef668580 100644 --- a/codegen/service/templates/type_validate.go.tpl +++ b/codegen/service/templates/type_validate.go.tpl @@ -1,32 +1,36 @@ {{- if .IsViewed -}} switch {{ .ArgVar }}.View { - {{- range .Views }} -case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: - err = Validate{{ $.Projected }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}.Projected) + {{- range .ValidationCalls }} +case {{ printf "%q" .View }}{{ if .Default }}, ""{{ end }}: + {{- if .Declaration }} + err = {{ .Declaration.Name }}({{ $.ArgVar }}.Projected) + {{- end }} {{- end }} default: - err = goa.InvalidEnumValueError("view", {{ .Source }}.View, []any{ {{ range .Views }}{{ printf "%q" .Name }}, {{ end }} }) + err = goa.InvalidEnumValueError("view", {{ .Source }}.View, []any{ {{ range .ValidationCalls }}{{ printf "%q" .View }}, {{ end }} }) } {{- else -}} {{- if .IsCollection -}} for _, {{ $.Source }} := range {{ $.ArgVar }} { - if err2 := {{ .ValidateVar }}({{ $.Source }}); err2 != nil { + if err2 := {{ .ValidateCall.Declaration.Name }}({{ $.Source }}); err2 != nil { err = goa.MergeErrors(err, err2) } } {{- else -}} - {{ .Validate }} - {{- range .Fields -}} - {{- if .IsRequired -}} + {{ .Validate }} + {{- range .Fields }} + {{- if .IsRequired }} if {{ $.Source }}.{{ goify .Name true }} == nil { err = goa.MergeErrors(err, goa.MissingFieldError({{ printf "%q" .Name }}, {{ printf "%q" $.Source }})) } - {{- end }} + {{- end }} + {{- if .Call }} if {{ $.Source }}.{{ goify .Name true }} != nil { - if err2 := {{ .ValidateVar }}({{ $.Source }}.{{ goify .Name true }}); err2 != nil { + if err2 := {{ .Call.Declaration.Name }}({{ $.Source }}.{{ goify .Name true }}); err2 != nil { err = goa.MergeErrors(err, err2) } } - {{- end -}} + {{- end }} + {{- end }} {{- end -}} {{- end -}} diff --git a/codegen/service/templates/union_type.go.tpl b/codegen/service/templates/union_type.go.tpl index 1c61987691..9957d801e3 100644 --- a/codegen/service/templates/union_type.go.tpl +++ b/codegen/service/templates/union_type.go.tpl @@ -1,71 +1,71 @@ -{{- /* Union sum-type definition and helpers. */ -}} +{{- /* Definition and helpers for a value that holds exactly one branch. */ -}} {{- range .Fields }} {{- if .EmitPrimitiveAlias }} type {{ .FieldType }} {{ .PrimitiveAliasType }} {{- end }} {{- end }} -// {{ .Name }} is a sum-type union. -type {{ .Name }} struct { - kind {{ .KindName }} +// {{ .TypeDeclaration.Name }} holds exactly one of its branch values. +type {{ .TypeDeclaration.Name }} struct { + kind {{ .KindDeclaration.Name }} {{- range .Fields }} {{ .FieldName }} {{ .FieldType }} {{- end }} } -// {{ .KindName }} enumerates the union variants for {{ .Name }}. -type {{ .KindName }} string +// {{ .KindDeclaration.Name }} records which {{ .TypeDeclaration.Name }} branch is selected. +type {{ .KindDeclaration.Name }} string const ( {{- range .Fields }} - // {{ .KindConst }} identifies the {{ .Name }} branch of the union. - {{ .KindConst }} {{ $.KindName }} = "{{ .TypeTag }}" + // {{ .KindDeclaration.Name }} identifies the {{ .Name }} branch. + {{ .KindDeclaration.Name }} {{ $.KindDeclaration.Name }} = "{{ .TypeTag }}" {{- end }} ) -// Kind returns the discriminator value of the union. -func (u {{ .Name }}) Kind() {{ .KindName }} { +// Kind returns the selected branch. +func (u {{ .TypeDeclaration.Name }}) Kind() {{ .KindDeclaration.Name }} { return u.kind } {{- range .Fields }} -// New{{ $.Name }}{{ .FieldName }} constructs {{ $.Name }} with the {{ .Name }} branch set. -func New{{ $.Name }}{{ .FieldName }}(v {{ .FieldType }}) {{ $.Name }} { - return {{ $.Name }}{ - kind: {{ .KindConst }}, +// {{ .ConstructorDeclaration.Name }} constructs {{ $.TypeDeclaration.Name }} with the {{ .Name }} branch set. +func {{ .ConstructorDeclaration.Name }}(v {{ .FieldType }}) {{ $.TypeDeclaration.Name }} { + return {{ $.TypeDeclaration.Name }}{ + kind: {{ .KindDeclaration.Name }}, {{ .FieldName }}: v, } } -// As{{ .FieldName }} returns the value of the {{ .Name }} branch if set. -func (u {{ $.Name }}) As{{ .FieldName }}() (_ {{ .FieldType }}, ok bool) { - if u.kind != {{ .KindConst }} { +// As{{ .FieldName }} returns the value when the {{ .Name }} branch is selected. +func (u {{ $.TypeDeclaration.Name }}) As{{ .FieldName }}() (_ {{ .FieldType }}, ok bool) { + if u.kind != {{ .KindDeclaration.Name }} { return } return u.{{ .FieldName }}, true } -// Set{{ .FieldName }} sets the {{ .Name }} branch of the union. -func (u *{{ $.Name }}) Set{{ .FieldName }}(v {{ .FieldType }}) { - u.kind = {{ .KindConst }} +// Set{{ .FieldName }} selects the {{ .Name }} branch and stores v. +func (u *{{ $.TypeDeclaration.Name }}) Set{{ .FieldName }}(v {{ .FieldType }}) { + u.kind = {{ .KindDeclaration.Name }} u.{{ .FieldName }} = v } {{- end }} -// Validate ensures the union discriminant is valid. -func (u {{ .Name }}) Validate() error { +// Validate ensures exactly one valid branch is selected. +func (u {{ .TypeDeclaration.Name }}) Validate() error { switch u.kind { case "": return goa.InvalidEnumValueError({{ printf "%q" .TypeKey }}, "", []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) {{- range .Fields }} - case {{ .KindConst }}: + case {{ .KindDeclaration.Name }}: {{- if .Nilable }} if u.{{ .FieldName }} == nil { - return goa.MissingFieldError({{ printf "%q" $.ValueKey }}, "{{ $.Name }}") + return goa.MissingFieldError({{ printf "%q" $.ValueKey }}, "{{ $.TypeDeclaration.Name }}") } {{- end }} return nil @@ -73,14 +73,14 @@ func (u {{ .Name }}) Validate() error { default: return goa.InvalidEnumValueError({{ printf "%q" $.TypeKey }}, u.kind, []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) } } // MarshalJSON marshals the union into the canonical {type,value} JSON shape. -func (u {{ .Name }}) MarshalJSON() ([]byte, error) { +func (u {{ .TypeDeclaration.Name }}) MarshalJSON() ([]byte, error) { if err := u.Validate(); err != nil { return nil, err } @@ -89,11 +89,11 @@ func (u {{ .Name }}) MarshalJSON() ([]byte, error) { ) switch u.kind { {{- range .Fields }} - case {{ .KindConst }}: + case {{ .KindDeclaration.Name }}: value = u.{{ .FieldName }} {{- end }} default: - return nil, fmt.Errorf("unexpected {{ .Name }} discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected {{ .TypeDeclaration.Name }} kind %q", u.kind) } return json.Marshal(struct { Type string {{ printf "`json:\"%s\"`" .TypeKey }} @@ -105,7 +105,7 @@ func (u {{ .Name }}) MarshalJSON() ([]byte, error) { } // UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape. -func (u *{{ .Name }}) UnmarshalJSON(data []byte) error { +func (u *{{ .TypeDeclaration.Name }}) UnmarshalJSON(data []byte) error { var raw struct { Type string {{ printf "`json:\"%s\"`" .TypeKey }} Value json.RawMessage {{ printf "`json:\"%s\"`" .ValueKey }} @@ -114,28 +114,28 @@ func (u *{{ .Name }}) UnmarshalJSON(data []byte) error { return err } if len(raw.Value) == 0 { - return goa.MissingFieldError({{ printf "%q" .ValueKey }}, "{{ .Name }}") + return goa.MissingFieldError({{ printf "%q" .ValueKey }}, "{{ .TypeDeclaration.Name }}") } if bytes.Equal(bytes.TrimSpace(raw.Value), []byte("null")) { return goa.InvalidFieldTypeError({{ printf "%q" .ValueKey }}, nil, "non-null JSON value") } switch raw.Type { {{- range .Fields }} - case string({{ .KindConst }}): + case string({{ .KindDeclaration.Name }}): var v {{ .FieldType }} if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = {{ .KindConst }} + u.kind = {{ .KindDeclaration.Name }} u.{{ .FieldName }} = v {{- end }} default: if raw.Type == "" { - return goa.MissingFieldError({{ printf "%q" .TypeKey }}, "{{ .Name }}") + return goa.MissingFieldError({{ printf "%q" .TypeKey }}, "{{ .TypeDeclaration.Name }}") } return goa.InvalidEnumValueError({{ printf "%q" .TypeKey }}, raw.Type, []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) } diff --git a/codegen/service/templates/validate.go.tpl b/codegen/service/templates/validate.go.tpl index 2ba16dc703..e427affc10 100644 --- a/codegen/service/templates/validate.go.tpl +++ b/codegen/service/templates/validate.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}(result {{ .Ref }}) (err error) { +func {{ .Declaration.Name }}(result {{ .Ref }}) (err error) { {{ .Validate }} return } diff --git a/codegen/service/templates/viewed_type_map.go.tpl b/codegen/service/templates/viewed_type_map.go.tpl index 249b96c8a9..e3c88ee849 100644 --- a/codegen/service/templates/viewed_type_map.go.tpl +++ b/codegen/service/templates/viewed_type_map.go.tpl @@ -1,7 +1,7 @@ var ( {{- range .ViewedTypes }} - {{ printf "%sMap is a map indexing the attribute names of %s by view name." .Name .Name | comment }} - {{ .Name }}Map = map[string][]string{ + {{ printf "%s is a map indexing the attribute names of %s by view name." .Declaration.Name .TypeName | comment }} + {{ .Declaration.Name }} = map[string][]string{ {{- range .Views }} "{{ .Name }}": { {{- range $n := .Attributes }} diff --git a/codegen/service/test_helpers_test.go b/codegen/service/test_helpers_test.go new file mode 100644 index 0000000000..820f5813af --- /dev/null +++ b/codegen/service/test_helpers_test.go @@ -0,0 +1,43 @@ +// This file provides strict construction helpers for service code-generation +// tests whose package roots and planner claims are deliberately valid. +package service + +import ( + "path" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// mustTestGeneration creates one generation or fails the calling test. +func mustTestGeneration(t *testing.T, genpkg string, roots []eval.Root) *codegen.Generation { + t.Helper() + generation, err := codegen.NewGeneration(genpkg, roots) + require.NoError(t, err) + return generation +} + +// mustClaimTestPackage claims one valid planner path or fails the calling test. +func mustClaimTestPackage(t *testing.T, generation *codegen.Generation, path string) *codegen.GeneratedPackage { + t.Helper() + generatedPackage, err := generation.ClaimPackage(path) + require.NoError(t, err) + return generatedPackage +} + +// planTestServices collects one retained service plan when the test exercises +// declaration collection separately from post-freeze linking. +func planTestServices(root *expr.RootExpr, generation *codegen.Generation) error { + _, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + return err +} + +// servicePackagePath returns the natural generated package path used by tests +// that build one noncolliding service package directly. +func servicePackagePath(genpkg string, service *expr.ServiceExpr) string { + return path.Join(genpkg, servicePackageName(service.Name)) +} diff --git a/codegen/service/testdata/a-nested-alpha/unused.go b/codegen/service/testdata/a-nested-alpha/unused.go new file mode 100644 index 0000000000..385890e057 --- /dev/null +++ b/codegen/service/testdata/a-nested-alpha/unused.go @@ -0,0 +1,8 @@ +// Package nestedalpha supplies an external field that the design deliberately +// does not map, so conversion planning can prove unused fields reserve nothing. +package nestedalpha + +// Child has the same package and type names as a mapped conversion field. +type Child struct { + Value string +} diff --git a/codegen/service/testdata/dedup_event_marker_dsls.go b/codegen/service/testdata/dedup_event_marker_dsls.go deleted file mode 100644 index 75f0625e3b..0000000000 --- a/codegen/service/testdata/dedup_event_marker_dsls.go +++ /dev/null @@ -1,26 +0,0 @@ -package testdata - -import ( - . "goa.design/goa/v3/dsl" -) - -// StreamingDuplicateResultTypesDSL defines two streaming methods that share the same -// result type to ensure event marker methods are not duplicated in generated service code. -var StreamingDuplicateResultTypesDSL = func() { - API("dedup-streaming", func() { JSONRPC(func() {}) }) - var SharedEvent = Type("SharedEvent", func() { - Attribute("message", String) - Required("message") - }) - Service("DupStreamService", func() { - JSONRPC(func() { POST("/stream") }) - Method("A", func() { - StreamingResult(SharedEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - Method("B", func() { - StreamingResult(SharedEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - }) -} diff --git a/codegen/service/testdata/endpoint_code.go b/codegen/service/testdata/endpoint_code.go index 32566bec02..12da85749c 100644 --- a/codegen/service/testdata/endpoint_code.go +++ b/codegen/service/testdata/endpoint_code.go @@ -149,6 +149,9 @@ func NewAEndpoint(s Service) goa.Endpoint { return nil, err } vres := NewViewedRtype(res, "default") + if err := withresultviews.ValidateRtype(vres); err != nil { + return nil, err + } return vres, nil } } @@ -185,6 +188,9 @@ func NewAEndpoint(s Service) goa.Endpoint { return nil, err } vres := NewViewedViewtype(res, "tiny") + if err := withresultmultipleviewsviews.ValidateViewtype(vres); err != nil { + return nil, err + } return vres, nil } } @@ -198,6 +204,9 @@ func NewBEndpoint(s Service) goa.Endpoint { return nil, err } vres := NewViewedViewtype(res, "default") + if err := withresultmultipleviewsviews.ValidateViewtype(vres); err != nil { + return nil, err + } return vres, nil } } diff --git a/codegen/service/testdata/endpoint_dsls.go b/codegen/service/testdata/endpoint_dsls.go index d118014c9a..a2dbb20355 100644 --- a/codegen/service/testdata/endpoint_dsls.go +++ b/codegen/service/testdata/endpoint_dsls.go @@ -138,6 +138,32 @@ var MixedResultsEndpointDSL = func() { }) } +var MixedResultsWithViewsEndpointDSL = func() { + var ResultType = ResultType("application/vnd.mixed-result", func() { + TypeName("MixedResult") + Attributes(func() { + Attribute("id", String) + Attribute("detail", String) + }) + View("default", func() { + Attribute("id") + }) + View("detailed", func() { + Attribute("id") + Attribute("detail") + }) + }) + var EventType = Type("MixedEvent", func() { + Attribute("message", String) + }) + Service("MixedResultsWithViewsEndpoint", func() { + Method("MixedResultsWithViewsMethod", func() { + Result(ResultType) + StreamingResult(EventType) + }) + }) +} + var StreamingPayloadEndpointDSL = func() { var AType = Type("AType", func() { Attribute("a", String) diff --git a/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden b/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden index 09d7f741b7..88a2fce5cb 100644 --- a/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden +++ b/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden @@ -7,10 +7,9 @@ package interceptors import ( - apiinterceptorservice "api_interceptor_service" "context" - "fmt" "goa.design/clue/log" + apiinterceptorservice "goa.design/goa/example/api_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type APIInterceptorServiceClientInterceptors struct { func NewAPIInterceptorServiceClientInterceptors() *APIInterceptorServiceClientInterceptors { return &APIInterceptorServiceClientInterceptors{} } -func (i *APIInterceptorServiceClientInterceptors) API(ctx context.Context, info *interceptors.APIInfo, next goa.Endpoint) (any, error) { +func (i *APIInterceptorServiceClientInterceptors) API(ctx context.Context, info apiinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden b/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden index 0cf0d6ca49..5f6ada02ef 100644 --- a/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden +++ b/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden @@ -7,10 +7,9 @@ package interceptors import ( - apiinterceptorservice "api_interceptor_service" "context" - "fmt" "goa.design/clue/log" + apiinterceptorservice "goa.design/goa/example/api_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type APIInterceptorServiceServerInterceptors struct { func NewAPIInterceptorServiceServerInterceptors() *APIInterceptorServiceServerInterceptors { return &APIInterceptorServiceServerInterceptors{} } -func (i *APIInterceptorServiceServerInterceptors) API(ctx context.Context, info *interceptors.APIInfo, next goa.Endpoint) (any, error) { +func (i *APIInterceptorServiceServerInterceptors) API(ctx context.Context, info apiinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden b/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden index b719d5252f..9249be24da 100644 --- a/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden +++ b/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden @@ -7,10 +7,9 @@ package interceptors import ( - chainedinterceptorservice "chained_interceptor_service" "context" - "fmt" "goa.design/clue/log" + chainedinterceptorservice "goa.design/goa/example/chained_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type ChainedInterceptorServiceClientInterceptors struct { func NewChainedInterceptorServiceClientInterceptors() *ChainedInterceptorServiceClientInterceptors { return &ChainedInterceptorServiceClientInterceptors{} } -func (i *ChainedInterceptorServiceClientInterceptors) API(ctx context.Context, info *interceptors.APIInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceClientInterceptors) API(ctx context.Context, info chainedinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *ChainedInterceptorServiceClientInterceptors) API(ctx context.Context, i log.Printf(ctx, "[API] Received response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceClientInterceptors) Method(ctx context.Context, info *interceptors.MethodInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceClientInterceptors) Method(ctx context.Context, info chainedinterceptorservice.MethodInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Method] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -42,7 +41,7 @@ func (i *ChainedInterceptorServiceClientInterceptors) Method(ctx context.Context log.Printf(ctx, "[Method] Received response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceClientInterceptors) Service(ctx context.Context, info *interceptors.ServiceInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceClientInterceptors) Service(ctx context.Context, info chainedinterceptorservice.ServiceInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Service] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden b/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden index e9a70a5564..8c55f6e51a 100644 --- a/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden +++ b/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden @@ -7,10 +7,9 @@ package interceptors import ( - chainedinterceptorservice "chained_interceptor_service" "context" - "fmt" "goa.design/clue/log" + chainedinterceptorservice "goa.design/goa/example/chained_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type ChainedInterceptorServiceServerInterceptors struct { func NewChainedInterceptorServiceServerInterceptors() *ChainedInterceptorServiceServerInterceptors { return &ChainedInterceptorServiceServerInterceptors{} } -func (i *ChainedInterceptorServiceServerInterceptors) API(ctx context.Context, info *interceptors.APIInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceServerInterceptors) API(ctx context.Context, info chainedinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *ChainedInterceptorServiceServerInterceptors) API(ctx context.Context, i log.Printf(ctx, "[API] Response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceServerInterceptors) Method(ctx context.Context, info *interceptors.MethodInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceServerInterceptors) Method(ctx context.Context, info chainedinterceptorservice.MethodInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Method] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -42,7 +41,7 @@ func (i *ChainedInterceptorServiceServerInterceptors) Method(ctx context.Context log.Printf(ctx, "[Method] Response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceServerInterceptors) Service(ctx context.Context, info *interceptors.ServiceInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceServerInterceptors) Service(ctx context.Context, info chainedinterceptorservice.ServiceInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Service] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden b/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden index 72713cbf1b..ed5daef245 100644 --- a/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden +++ b/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden @@ -7,10 +7,9 @@ package interceptors import ( - clientinterceptorservice "client_interceptor_service" "context" - "fmt" "goa.design/clue/log" + clientinterceptorservice "goa.design/goa/example/client_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type ClientInterceptorServiceClientInterceptors struct { func NewClientInterceptorServiceClientInterceptors() *ClientInterceptorServiceClientInterceptors { return &ClientInterceptorServiceClientInterceptors{} } -func (i *ClientInterceptorServiceClientInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *ClientInterceptorServiceClientInterceptors) Test(ctx context.Context, info clientinterceptorservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden index 55126006cb..39e416240c 100644 --- a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden +++ b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleinterceptorsservice "goa.design/goa/example/multiple_interceptors_service" goa "goa.design/goa/v3/pkg" - multipleinterceptorsservice "multiple_interceptors_service" ) // MultipleInterceptorsServiceClientInterceptors implements the client interceptors for the MultipleInterceptorsService service. @@ -22,7 +21,7 @@ type MultipleInterceptorsServiceClientInterceptors struct { func NewMultipleInterceptorsServiceClientInterceptors() *MultipleInterceptorsServiceClientInterceptors { return &MultipleInterceptorsServiceClientInterceptors{} } -func (i *MultipleInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info *interceptors.Test2Info, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info multipleinterceptorsservice.Test2Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test2] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleInterceptorsServiceClientInterceptors) Test2(ctx context.Contex log.Printf(ctx, "[Test2] Received response: %v", resp) return resp, nil } -func (i *MultipleInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info *interceptors.Test4Info, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info multipleinterceptorsservice.Test4Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test4] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden index 6ddbb67861..dfe0a82193 100644 --- a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden +++ b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleinterceptorsservice "goa.design/goa/example/multiple_interceptors_service" goa "goa.design/goa/v3/pkg" - multipleinterceptorsservice "multiple_interceptors_service" ) // MultipleInterceptorsServiceServerInterceptors implements the server interceptor for the MultipleInterceptorsService service. @@ -22,7 +21,7 @@ type MultipleInterceptorsServiceServerInterceptors struct { func NewMultipleInterceptorsServiceServerInterceptors() *MultipleInterceptorsServiceServerInterceptors { return &MultipleInterceptorsServiceServerInterceptors{} } -func (i *MultipleInterceptorsServiceServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceServerInterceptors) Test(ctx context.Context, info multipleinterceptorsservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleInterceptorsServiceServerInterceptors) Test(ctx context.Context log.Printf(ctx, "[Test] Response: %v", resp) return resp, nil } -func (i *MultipleInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info *interceptors.Test3Info, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info multipleinterceptorsservice.Test3Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test3] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden index be0c25041b..53d6685774 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleservicesinterceptorsservice2 "goa.design/goa/example/multiple_services_interceptors_service2" goa "goa.design/goa/v3/pkg" - multipleservicesinterceptorsservice2 "multiple_services_interceptors_service2" ) // MultipleServicesInterceptorsService2ClientInterceptors implements the client interceptors for the MultipleServicesInterceptorsService2 service. @@ -22,7 +21,7 @@ type MultipleServicesInterceptorsService2ClientInterceptors struct { func NewMultipleServicesInterceptorsService2ClientInterceptors() *MultipleServicesInterceptorsService2ClientInterceptors { return &MultipleServicesInterceptorsService2ClientInterceptors{} } -func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test2(ctx context.Context, info *interceptors.Test2Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test2(ctx context.Context, info multipleservicesinterceptorsservice2.Test2Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test2] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test2(ctx conte log.Printf(ctx, "[Test2] Received response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test4(ctx context.Context, info *interceptors.Test4Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test4(ctx context.Context, info multipleservicesinterceptorsservice2.Test4Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test4] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden index a36e8d46f5..0433a02bf3 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleservicesinterceptorsservice2 "goa.design/goa/example/multiple_services_interceptors_service2" goa "goa.design/goa/v3/pkg" - multipleservicesinterceptorsservice2 "multiple_services_interceptors_service2" ) // MultipleServicesInterceptorsService2ServerInterceptors implements the server interceptor for the MultipleServicesInterceptorsService2 service. @@ -22,7 +21,7 @@ type MultipleServicesInterceptorsService2ServerInterceptors struct { func NewMultipleServicesInterceptorsService2ServerInterceptors() *MultipleServicesInterceptorsService2ServerInterceptors { return &MultipleServicesInterceptorsService2ServerInterceptors{} } -func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test(ctx context.Context, info multipleservicesinterceptorsservice2.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test(ctx contex log.Printf(ctx, "[Test] Response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test3(ctx context.Context, info *interceptors.Test3Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test3(ctx context.Context, info multipleservicesinterceptorsservice2.Test3Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test3] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden index c344ea098b..5fe53ff08c 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleservicesinterceptorsservice "goa.design/goa/example/multiple_services_interceptors_service" goa "goa.design/goa/v3/pkg" - multipleservicesinterceptorsservice "multiple_services_interceptors_service" ) // MultipleServicesInterceptorsServiceClientInterceptors implements the client interceptors for the MultipleServicesInterceptorsService service. @@ -22,7 +21,7 @@ type MultipleServicesInterceptorsServiceClientInterceptors struct { func NewMultipleServicesInterceptorsServiceClientInterceptors() *MultipleServicesInterceptorsServiceClientInterceptors { return &MultipleServicesInterceptorsServiceClientInterceptors{} } -func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info *interceptors.Test2Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info multipleservicesinterceptorsservice.Test2Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test2] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test2(ctx contex log.Printf(ctx, "[Test2] Received response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info *interceptors.Test4Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info multipleservicesinterceptorsservice.Test4Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test4] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden index aa349d4854..12cab72bae 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleservicesinterceptorsservice "goa.design/goa/example/multiple_services_interceptors_service" goa "goa.design/goa/v3/pkg" - multipleservicesinterceptorsservice "multiple_services_interceptors_service" ) // MultipleServicesInterceptorsServiceServerInterceptors implements the server interceptor for the MultipleServicesInterceptorsService service. @@ -22,7 +21,7 @@ type MultipleServicesInterceptorsServiceServerInterceptors struct { func NewMultipleServicesInterceptorsServiceServerInterceptors() *MultipleServicesInterceptorsServiceServerInterceptors { return &MultipleServicesInterceptorsServiceServerInterceptors{} } -func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test(ctx context.Context, info multipleservicesinterceptorsservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test(ctx context log.Printf(ctx, "[Test] Response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info *interceptors.Test3Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info multipleservicesinterceptorsservice.Test3Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test3] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden b/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden index da77a3dedb..510d569f8b 100644 --- a/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden +++ b/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + serverinterceptorbynameservice "goa.design/goa/example/server_interceptor_by_name_service" goa "goa.design/goa/v3/pkg" - serverinterceptorbynameservice "server_interceptor_by_name_service" ) // ServerInterceptorByNameServiceServerInterceptors implements the server interceptor for the ServerInterceptorByNameService service. @@ -22,7 +21,7 @@ type ServerInterceptorByNameServiceServerInterceptors struct { func NewServerInterceptorByNameServiceServerInterceptors() *ServerInterceptorByNameServiceServerInterceptors { return &ServerInterceptorByNameServiceServerInterceptors{} } -func (i *ServerInterceptorByNameServiceServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *ServerInterceptorByNameServiceServerInterceptors) Test(ctx context.Context, info serverinterceptorbynameservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden b/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden index 7adff8b41a..001136cab3 100644 --- a/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden +++ b/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + serverinterceptorservice "goa.design/goa/example/server_interceptor_service" goa "goa.design/goa/v3/pkg" - serverinterceptorservice "server_interceptor_service" ) // ServerInterceptorServiceServerInterceptors implements the server interceptor for the ServerInterceptorService service. @@ -22,7 +21,7 @@ type ServerInterceptorServiceServerInterceptors struct { func NewServerInterceptorServiceServerInterceptors() *ServerInterceptorServiceServerInterceptors { return &ServerInterceptorServiceServerInterceptors{} } -func (i *ServerInterceptorServiceServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *ServerInterceptorServiceServerInterceptors) Test(ctx context.Context, info serverinterceptorservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/golden/example_service-mixed-results-with-views.go.golden b/codegen/service/testdata/golden/example_service-mixed-results-with-views.go.golden new file mode 100644 index 0000000000..3469fe70fe --- /dev/null +++ b/codegen/service/testdata/golden/example_service-mixed-results-with-views.go.golden @@ -0,0 +1,25 @@ +package testapi + +import ( + "context" + "goa.design/clue/log" + mixedresultswithviewsendpoint "goa.design/goa/example/mixed_results_with_views_endpoint" +) + +// MixedResultsWithViewsEndpoint service example implementation. +// The example methods log the requests and return zero values. +type mixedResultsWithViewsEndpointsrvc struct{} + +// NewMixedResultsWithViewsEndpoint returns the MixedResultsWithViewsEndpoint +// service implementation. +func NewMixedResultsWithViewsEndpoint() mixedresultswithviewsendpoint.Service { + return &mixedResultsWithViewsEndpointsrvc{} +} + +// MixedResultsWithViewsMethod implements MixedResultsWithViewsMethod. +func (s *mixedResultsWithViewsEndpointsrvc) MixedResultsWithViewsMethod(ctx context.Context, stream mixedresultswithviewsendpoint.MixedResultsWithViewsMethodServerStream) (res *mixedresultswithviewsendpoint.MixedResult, view string, err error) { + res = &mixedresultswithviewsendpoint.MixedResult{} + view = "default" + log.Printf(ctx, "mixedResultsWithViewsEndpoint.MixedResultsWithViewsMethod") + return +} diff --git a/codegen/service/testdata/golden/example_service-mixed-results.go.golden b/codegen/service/testdata/golden/example_service-mixed-results.go.golden new file mode 100644 index 0000000000..9589677a8f --- /dev/null +++ b/codegen/service/testdata/golden/example_service-mixed-results.go.golden @@ -0,0 +1,24 @@ +package testapi + +import ( + "context" + "goa.design/clue/log" + mixedresultsendpoint "goa.design/goa/example/mixed_results_endpoint" +) + +// MixedResultsEndpoint service example implementation. +// The example methods log the requests and return zero values. +type mixedResultsEndpointsrvc struct{} + +// NewMixedResultsEndpoint returns the MixedResultsEndpoint service +// implementation. +func NewMixedResultsEndpoint() mixedresultsendpoint.Service { + return &mixedResultsEndpointsrvc{} +} + +// MixedResultsMethod implements MixedResultsMethod. +func (s *mixedResultsEndpointsrvc) MixedResultsMethod(ctx context.Context, p *mixedresultsendpoint.Payload, stream mixedresultsendpoint.MixedResultsMethodServerStream) (res *mixedresultsendpoint.ResultType, err error) { + res = &mixedresultsendpoint.ResultType{} + log.Printf(ctx, "mixedResultsEndpoint.MixedResultsMethod") + return +} diff --git a/codegen/service/testdata/golden/pkg_path_array_foo.go.golden b/codegen/service/testdata/golden/pkg_path_array_foo.go.golden index 1469ba4254..229ca4ff20 100644 --- a/codegen/service/testdata/golden/pkg_path_array_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_array_foo.go.golden @@ -1,4 +1,4 @@ - +// Foo is a named type defined in the service design. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden b/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden index 7bd09a39e3..a4b734a959 100644 --- a/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden @@ -1,4 +1,8 @@ -// Foo is the payload type of the PkgPathDupeMethod service A method. +// Foo is used by these service methods: +// - PkgPathDupeMethod A: payload and result +// - PkgPathDupeMethod B: payload and result +// - PkgPathDupeMethod2 A: payload and result +// - PkgPathDupeMethod2 B: payload and result type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden b/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden index af590aae73..ee47af5611 100644 --- a/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden +++ b/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden @@ -1,4 +1,5 @@ -// Bar is the payload type of the MultiplePkgPathMethod service A method. +// Bar is the payload and result type of the MultiplePkgPathMethod service A +// method. type Bar struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden b/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden index 2e6028eeb1..ea6948e82c 100644 --- a/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden +++ b/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden @@ -1,4 +1,5 @@ -// Baz is the payload type of the MultiplePkgPathMethod service B method. +// Baz is the payload and result type of the MultiplePkgPathMethod service B +// method. type Baz struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden b/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden index 1469ba4254..229ca4ff20 100644 --- a/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden @@ -1,4 +1,4 @@ - +// Foo is a named type defined in the service design. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_payload_attribute_service.go.golden b/codegen/service/testdata/golden/pkg_path_payload_attribute_service.go.golden index c52656257a..2c78cff2cd 100644 --- a/codegen/service/testdata/golden/pkg_path_payload_attribute_service.go.golden +++ b/codegen/service/testdata/golden/pkg_path_payload_attribute_service.go.golden @@ -2,7 +2,7 @@ // Service is the PkgPathPayloadAttributeDSL service interface. type Service interface { // Foo implements Foo. - FooEndpoint(context.Context, *Bar) (res *Bar, err error) + Foo(context.Context, *Bar) (res *Bar, err error) } // APIName is the name of the API as defined in the design. diff --git a/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden b/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden index 1469ba4254..229ca4ff20 100644 --- a/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden @@ -1,4 +1,4 @@ - +// Foo is a named type defined in the service design. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden b/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden index 1d4d0e1fdb..f66c17de5b 100644 --- a/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden @@ -1,5 +1,5 @@ -// RecursiveFoo is the payload type of the PkgPathRecursiveMethod service A -// method. +// RecursiveFoo is the payload and result type of the PkgPathRecursiveMethod +// service A method. type RecursiveFoo struct { Foo *Foo } diff --git a/codegen/service/testdata/golden/pkg_path_shared_roles_service.go.golden b/codegen/service/testdata/golden/pkg_path_shared_roles_service.go.golden new file mode 100644 index 0000000000..9751c6d575 --- /dev/null +++ b/codegen/service/testdata/golden/pkg_path_shared_roles_service.go.golden @@ -0,0 +1,54 @@ + +// Service is the PkgPathSharedRoles service interface. +type Service interface { + // Exchange implements Exchange. + Exchange(context.Context, *shared.Shared, ExchangeServerStream) (res *shared.Shared, err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "test api" + +// 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 = "PkgPathSharedRoles" + +// 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 = [1]string{"Exchange"} + +// ExchangeServerStream allows streaming instances of *shared.Shared to the +// client. +type ExchangeServerStream interface { + // Send streams instances of "shared.Shared". + Send(*shared.Shared) error + // SendWithContext streams instances of "shared.Shared" with context. + SendWithContext(context.Context, *shared.Shared) error + // Recv reads instances of "shared.Shared" from the stream. + Recv() (*shared.Shared, error) + // RecvWithContext reads instances of "shared.Shared" from the stream with + // context. + RecvWithContext(context.Context) (*shared.Shared, error) + // Close closes the stream. + Close() error +} + +// ExchangeClientStream allows streaming instances of *shared.Shared to the +// client. +type ExchangeClientStream interface { + // Send streams instances of "shared.Shared". + Send(*shared.Shared) error + // SendWithContext streams instances of "shared.Shared" with context. + SendWithContext(context.Context, *shared.Shared) error + // Recv reads instances of "shared.Shared" from the stream. + Recv() (*shared.Shared, error) + // RecvWithContext reads instances of "shared.Shared" from the stream with + // context. + RecvWithContext(context.Context) (*shared.Shared, error) + // Close closes the stream. + Close() error +} diff --git a/codegen/service/testdata/golden/pkg_path_shared_roles_shared.go.golden b/codegen/service/testdata/golden/pkg_path_shared_roles_shared.go.golden new file mode 100644 index 0000000000..750d39f997 --- /dev/null +++ b/codegen/service/testdata/golden/pkg_path_shared_roles_shared.go.golden @@ -0,0 +1,5 @@ +// Shared is the payload, streaming payload, result, and streaming result type +// of the PkgPathSharedRoles service Exchange method. +type Shared struct { + IntField *int +} diff --git a/codegen/service/testdata/golden/pkg_path_single_foo.go.golden b/codegen/service/testdata/golden/pkg_path_single_foo.go.golden index 38cad09709..f7ff6cd37b 100644 --- a/codegen/service/testdata/golden/pkg_path_single_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_single_foo.go.golden @@ -1,4 +1,4 @@ -// Foo is the payload type of the PkgPathMethod service A method. +// Foo is the payload and result type of the PkgPathMethod service A method. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-explicit-view.go.golden b/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-explicit-view.go.golden index 86076a3ff5..308cb32cfd 100644 --- a/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-explicit-view.go.golden +++ b/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-explicit-view.go.golden @@ -78,7 +78,7 @@ func NewMultipleViews(vres *bidirectionalstreamingresultwithexplicitviewservicev // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *bidirectionalstreamingresultwithexplicitviewserviceviews.MultipleViews { - var vres *bidirectionalstreamingresultwithexplicitviewserviceviews.MultipleViews + vres := &bidirectionalstreamingresultwithexplicitviewserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-views.go.golden b/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-views.go.golden index bbf9ce1e4a..bc9948fd96 100644 --- a/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-views.go.golden +++ b/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-views.go.golden @@ -94,7 +94,7 @@ func NewMultipleViews(vres *bidirectionalstreamingresultwithviewsserviceviews.Mu // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *bidirectionalstreamingresultwithviewsserviceviews.MultipleViews { - var vres *bidirectionalstreamingresultwithviewsserviceviews.MultipleViews + vres := &bidirectionalstreamingresultwithviewsserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-multi-union.go.golden b/codegen/service/testdata/golden/service_service-multi-union.go.golden index 94cb54030e..594b7ba7c7 100644 --- a/codegen/service/testdata/golden/service_service-multi-union.go.golden +++ b/codegen/service/testdata/golden/service_service-multi-union.go.golden @@ -34,24 +34,24 @@ type Union struct { Values Values } -// Values is a sum-type union. +// Values holds exactly one of its branch values. type Values struct { kind ValuesKind A *TypeA B *TypeB } -// ValuesKind enumerates the union variants for Values. +// ValuesKind records which Values branch is selected. type ValuesKind string const ( - // ValuesKindA identifies the a branch of the union. + // ValuesKindA identifies the a branch. ValuesKindA ValuesKind = "a" - // ValuesKindB identifies the b branch of the union. + // ValuesKindB identifies the b branch. ValuesKindB ValuesKind = "b" ) -// Kind returns the discriminator value of the union. +// Kind returns the selected branch. func (u Values) Kind() ValuesKind { return u.kind } @@ -64,7 +64,7 @@ func NewValuesA(v *TypeA) Values { } } -// AsA returns the value of the a branch if set. +// AsA returns the value when the a branch is selected. func (u Values) AsA() (_ *TypeA, ok bool) { if u.kind != ValuesKindA { return @@ -72,7 +72,7 @@ func (u Values) AsA() (_ *TypeA, ok bool) { return u.A, true } -// SetA sets the a branch of the union. +// SetA selects the a branch and stores v. func (u *Values) SetA(v *TypeA) { u.kind = ValuesKindA u.A = v @@ -86,7 +86,7 @@ func NewValuesB(v *TypeB) Values { } } -// AsB returns the value of the b branch if set. +// AsB returns the value when the b branch is selected. func (u Values) AsB() (_ *TypeB, ok bool) { if u.kind != ValuesKindB { return @@ -94,13 +94,13 @@ func (u Values) AsB() (_ *TypeB, ok bool) { return u.B, true } -// SetB sets the b branch of the union. +// SetB selects the b branch and stores v. func (u *Values) SetB(v *TypeB) { u.kind = ValuesKindB u.B = v } -// Validate ensures the union discriminant is valid. +// Validate ensures exactly one valid branch is selected. func (u Values) Validate() error { switch u.kind { case "": @@ -140,7 +140,7 @@ func (u Values) MarshalJSON() ([]byte, error) { case ValuesKindB: value = u.B default: - return nil, fmt.Errorf("unexpected Values discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected Values kind %q", u.kind) } return json.Marshal(struct { Type string `json:"type"` diff --git a/codegen/service/testdata/golden/service_service-repeated-inline-errors.go.golden b/codegen/service/testdata/golden/service_service-repeated-inline-errors.go.golden new file mode 100644 index 0000000000..2e573b0a27 --- /dev/null +++ b/codegen/service/testdata/golden/service_service-repeated-inline-errors.go.golden @@ -0,0 +1,45 @@ + +// Service is the Secured service interface. +type Service interface { + // Read implements Read. + Read(context.Context) (err error) + // Write implements Write. + Write(context.Context) (err error) + // Delete implements Delete. + Delete(context.Context) (err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "test api" + +// 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 = "Secured" + +// 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 = [3]string{"Read", "Write", "Delete"} + +type InvalidScopes string + +// Error returns an error description. +func (e InvalidScopes) Error() string { + return "" +} + +// ErrorName returns the error name. +// +// Deprecated: Use GoaErrorName - https://github.com/goadesign/goa/issues/3105 +func (e InvalidScopes) ErrorName() string { + return e.GoaErrorName() +} + +// GoaErrorName returns the error name. +func (e InvalidScopes) GoaErrorName() string { + return "invalid_scopes" +} diff --git a/codegen/service/testdata/golden/service_service-result-collection-multiple-views.go.golden b/codegen/service/testdata/golden/service_service-result-collection-multiple-views.go.golden index e2b842d658..de2b29f4c9 100644 --- a/codegen/service/testdata/golden/service_service-result-collection-multiple-views.go.golden +++ b/codegen/service/testdata/golden/service_service-result-collection-multiple-views.go.golden @@ -50,7 +50,7 @@ func NewMultipleViewsCollection(vres resultcollectionmultipleviewsmethodviews.Mu // MultipleViewsCollection from result type MultipleViewsCollection using the // given view. func NewViewedMultipleViewsCollection(res MultipleViewsCollection, view string) resultcollectionmultipleviewsmethodviews.MultipleViewsCollection { - var vres resultcollectionmultipleviewsmethodviews.MultipleViewsCollection + vres := resultcollectionmultipleviewsmethodviews.MultipleViewsCollection{View: view} switch view { case "default", "": p := newMultipleViewsCollectionView(res) diff --git a/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden b/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden index 3441c502e8..74471509f2 100644 --- a/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden @@ -6,8 +6,8 @@ type Service interface { // - "default" // - "tiny" A(context.Context) (res *MultipleViews, view string, err error) - // A implements A. - AEndpoint(context.Context) (res *MultipleViews, err error) + // B implements B. + B(context.Context) (res *MultipleViews, err error) } // APIName is the name of the API as defined in the design. @@ -24,7 +24,7 @@ const ServiceName = "WithExplicitAndDefaultViews" // 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 = [2]string{"A", "A"} +var MethodNames = [2]string{"A", "B"} // MultipleViews is the result type of the WithExplicitAndDefaultViews service // A method. @@ -49,7 +49,7 @@ func NewMultipleViews(vres *withexplicitanddefaultviewsviews.MultipleViews) *Mul // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *withexplicitanddefaultviewsviews.MultipleViews { - var vres *withexplicitanddefaultviewsviews.MultipleViews + vres := &withexplicitanddefaultviewsviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden b/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden index 3beeca448a..46d1ed37b8 100644 --- a/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden @@ -51,6 +51,21 @@ func NewViewedResultInlineValidation(res *ResultInlineValidation, view string) * return &resultwithinlinevalidationviews.ResultInlineValidation{Projected: p, View: "default"} } +// NewResultInlineValidationBResult initializes result type +// ResultInlineValidationBResult from viewed result type +// ResultInlineValidationBResult. +func NewResultInlineValidationBResult(vres *resultwithinlinevalidationviews.ResultInlineValidationBResult) *ResultInlineValidationBResult { + return newResultInlineValidationBResult(vres.Projected) +} + +// NewViewedResultInlineValidationBResult initializes viewed result type +// ResultInlineValidationBResult from result type ResultInlineValidationBResult +// using the given view. +func NewViewedResultInlineValidationBResult(res *ResultInlineValidationBResult, view string) *resultwithinlinevalidationviews.ResultInlineValidationBResult { + p := newResultInlineValidationBResultView(res) + return &resultwithinlinevalidationviews.ResultInlineValidationBResult{Projected: p, View: "default"} +} + // newResultInlineValidation converts projected type ResultInlineValidation to // service type ResultInlineValidation. func newResultInlineValidation(vres *resultwithinlinevalidationviews.ResultInlineValidationView) *ResultInlineValidation { @@ -70,3 +85,26 @@ func newResultInlineValidationView(res *ResultInlineValidation) *resultwithinlin } return vres } + +// newResultInlineValidationBResult converts projected type +// ResultInlineValidationBResult to service type ResultInlineValidationBResult. +func newResultInlineValidationBResult(vres *resultwithinlinevalidationviews.ResultInlineValidationBResultView) *ResultInlineValidationBResult { + res := &ResultInlineValidationBResult{ + B: vres.B, + } + if vres.A != nil { + res.A = *vres.A + } + return res +} + +// newResultInlineValidationBResultView projects result type +// ResultInlineValidationBResult to projected type +// ResultInlineValidationBResultView using the "default" view. +func newResultInlineValidationBResultView(res *ResultInlineValidationBResult) *resultwithinlinevalidationviews.ResultInlineValidationBResultView { + vres := &resultwithinlinevalidationviews.ResultInlineValidationBResultView{ + A: &res.A, + B: res.B, + } + return vres +} diff --git a/codegen/service/testdata/golden/service_service-result-with-multiple-views.go.golden b/codegen/service/testdata/golden/service_service-result-with-multiple-views.go.golden index ce5121e19e..d83c151637 100644 --- a/codegen/service/testdata/golden/service_service-result-with-multiple-views.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-multiple-views.go.golden @@ -66,7 +66,7 @@ func NewMultipleViews(vres *multiplemethodsresultmultipleviewsviews.MultipleView // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *multiplemethodsresultmultipleviewsviews.MultipleViews { - var vres *multiplemethodsresultmultipleviewsviews.MultipleViews + vres := &multiplemethodsresultmultipleviewsviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-result-with-one-of-type.go.golden b/codegen/service/testdata/golden/service_service-result-with-one-of-type.go.golden index 1187d54b64..34b64f592b 100644 --- a/codegen/service/testdata/golden/service_service-result-with-one-of-type.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-one-of-type.go.golden @@ -38,24 +38,24 @@ type U struct { Item *Item } -// Result is a sum-type union. +// Result holds exactly one of its branch values. type Result struct { kind ResultKind T *T U *U } -// ResultKind enumerates the union variants for Result. +// ResultKind records which Result branch is selected. type ResultKind string const ( - // ResultKindT identifies the t branch of the union. + // ResultKindT identifies the t branch. ResultKindT ResultKind = "t" - // ResultKindU identifies the u branch of the union. + // ResultKindU identifies the u branch. ResultKindU ResultKind = "u" ) -// Kind returns the discriminator value of the union. +// Kind returns the selected branch. func (u Result) Kind() ResultKind { return u.kind } @@ -68,7 +68,7 @@ func NewResultT(v *T) Result { } } -// AsT returns the value of the t branch if set. +// AsT returns the value when the t branch is selected. func (u Result) AsT() (_ *T, ok bool) { if u.kind != ResultKindT { return @@ -76,7 +76,7 @@ func (u Result) AsT() (_ *T, ok bool) { return u.T, true } -// SetT sets the t branch of the union. +// SetT selects the t branch and stores v. func (u *Result) SetT(v *T) { u.kind = ResultKindT u.T = v @@ -90,7 +90,7 @@ func NewResultU(v *U) Result { } } -// AsU returns the value of the u branch if set. +// AsU returns the value when the u branch is selected. func (u Result) AsU() (_ *U, ok bool) { if u.kind != ResultKindU { return @@ -98,13 +98,13 @@ func (u Result) AsU() (_ *U, ok bool) { return u.U, true } -// SetU sets the u branch of the union. +// SetU selects the u branch and stores v. func (u *Result) SetU(v *U) { u.kind = ResultKindU u.U = v } -// Validate ensures the union discriminant is valid. +// Validate ensures exactly one valid branch is selected. func (u Result) Validate() error { switch u.kind { case "": @@ -144,7 +144,7 @@ func (u Result) MarshalJSON() ([]byte, error) { case ResultKindU: value = u.U default: - return nil, fmt.Errorf("unexpected Result discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected Result kind %q", u.kind) } return json.Marshal(struct { Type string `json:"type"` @@ -218,13 +218,19 @@ func newResultOneof(vres *resultwithoneoftypeviews.ResultOneofView) *ResultOneof switch string(vres.Result.Kind()) { case "t": actual, _ := vres.Result.AsT() - obj := transformResultwithoneoftypeviewsTViewToT(actual) + var obj *T + if actual != nil { + obj = transformResultwithoneoftypeviewsTViewToT(actual) + } u := res.Result u.SetT((*T)(obj)) res.Result = u case "u": actual, _ := vres.Result.AsU() - obj := transformResultwithoneoftypeviewsUViewToU(actual) + var obj *U + if actual != nil { + obj = transformResultwithoneoftypeviewsUViewToU(actual) + } u := res.Result u.SetU((*U)(obj)) res.Result = u @@ -241,13 +247,19 @@ func newResultOneofView(res *ResultOneof) *resultwithoneoftypeviews.ResultOneofV switch string(res.Result.Kind()) { case "t": actual, _ := res.Result.AsT() - obj := transformTToResultwithoneoftypeviewsTView(actual) + var obj *resultwithoneoftypeviews.TView + if actual != nil { + obj = transformTToResultwithoneoftypeviewsTView(actual) + } u := vres.Result u.SetT((*resultwithoneoftypeviews.TView)(obj)) vres.Result = u case "u": actual, _ := res.Result.AsU() - obj := transformUToResultwithoneoftypeviewsUView(actual) + var obj *resultwithoneoftypeviews.UView + if actual != nil { + obj = transformUToResultwithoneoftypeviewsUView(actual) + } u := vres.Result u.SetU((*resultwithoneoftypeviews.UView)(obj)) vres.Result = u diff --git a/codegen/service/testdata/golden/service_service-result-with-other-result.go.golden b/codegen/service/testdata/golden/service_service-result-with-other-result.go.golden index 03fba976c1..0874c7ec4d 100644 --- a/codegen/service/testdata/golden/service_service-result-with-other-result.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-other-result.go.golden @@ -52,7 +52,7 @@ func NewMultipleViews(vres *resultwithotherresultviews.MultipleViews) *MultipleV // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *resultwithotherresultviews.MultipleViews { - var vres *resultwithotherresultviews.MultipleViews + vres := &resultwithotherresultviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-result-with-result-collection.go.golden b/codegen/service/testdata/golden/service_service-result-with-result-collection.go.golden index 97dae9288a..13d5e56da1 100644 --- a/codegen/service/testdata/golden/service_service-result-with-result-collection.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-result-collection.go.golden @@ -55,7 +55,7 @@ func NewRT(vres *resultwithresulttypecollectionviews.RT) *RT { // NewViewedRT initializes viewed result type RT from result type RT using the // given view. func NewViewedRT(res *RT, view string) *resultwithresulttypecollectionviews.RT { - var vres *resultwithresulttypecollectionviews.RT + vres := &resultwithresulttypecollectionviews.RT{View: view} switch view { case "default", "": p := newRTView(res) diff --git a/codegen/service/testdata/golden/service_service-streaming-payload-result-with-explicit-view.go.golden b/codegen/service/testdata/golden/service_service-streaming-payload-result-with-explicit-view.go.golden index d2ad9640c4..4d30cd2696 100644 --- a/codegen/service/testdata/golden/service_service-streaming-payload-result-with-explicit-view.go.golden +++ b/codegen/service/testdata/golden/service_service-streaming-payload-result-with-explicit-view.go.golden @@ -76,7 +76,7 @@ func NewMultipleViews(vres *streamingpayloadresultwithexplicitviewserviceviews.M // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *streamingpayloadresultwithexplicitviewserviceviews.MultipleViews { - var vres *streamingpayloadresultwithexplicitviewserviceviews.MultipleViews + vres := &streamingpayloadresultwithexplicitviewserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-streaming-payload-result-with-views.go.golden b/codegen/service/testdata/golden/service_service-streaming-payload-result-with-views.go.golden index 5492f7b9c0..4c3fa99d45 100644 --- a/codegen/service/testdata/golden/service_service-streaming-payload-result-with-views.go.golden +++ b/codegen/service/testdata/golden/service_service-streaming-payload-result-with-views.go.golden @@ -91,7 +91,7 @@ func NewMultipleViews(vres *streamingpayloadresultwithviewsserviceviews.Multiple // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *streamingpayloadresultwithviewsserviceviews.MultipleViews { - var vres *streamingpayloadresultwithviewsserviceviews.MultipleViews + vres := &streamingpayloadresultwithviewsserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-streaming-result-with-explicit-view.go.golden b/codegen/service/testdata/golden/service_service-streaming-result-with-explicit-view.go.golden index 7aad6c247f..da94ce1180 100644 --- a/codegen/service/testdata/golden/service_service-streaming-result-with-explicit-view.go.golden +++ b/codegen/service/testdata/golden/service_service-streaming-result-with-explicit-view.go.golden @@ -67,7 +67,7 @@ func NewMultipleViews(vres *streamingresultwithexplicitviewserviceviews.Multiple // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *streamingresultwithexplicitviewserviceviews.MultipleViews { - var vres *streamingresultwithexplicitviewserviceviews.MultipleViews + vres := &streamingresultwithexplicitviewserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-streaming-result-with-views.go.golden b/codegen/service/testdata/golden/service_service-streaming-result-with-views.go.golden index 5053b27209..12aa65c247 100644 --- a/codegen/service/testdata/golden/service_service-streaming-result-with-views.go.golden +++ b/codegen/service/testdata/golden/service_service-streaming-result-with-views.go.golden @@ -70,7 +70,7 @@ func NewMultipleViews(vres *streamingresultwithviewsserviceviews.MultipleViews) // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *streamingresultwithviewsserviceviews.MultipleViews { - var vres *streamingresultwithviewsserviceviews.MultipleViews + vres := &streamingresultwithviewsserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-union-alias-cross-pkg.go.golden b/codegen/service/testdata/golden/service_service-union-alias-cross-pkg.go.golden index 0b42c17758..f790e24ecd 100644 --- a/codegen/service/testdata/golden/service_service-union-alias-cross-pkg.go.golden +++ b/codegen/service/testdata/golden/service_service-union-alias-cross-pkg.go.golden @@ -26,24 +26,24 @@ type Scope struct { Values Values } -// Values is a sum-type union. +// Values holds exactly one of its branch values. type Values struct { kind ValuesKind Device alias.Alias Metric alias.Alias } -// ValuesKind enumerates the union variants for Values. +// ValuesKind records which Values branch is selected. type ValuesKind string const ( - // ValuesKindDevice identifies the Device branch of the union. + // ValuesKindDevice identifies the Device branch. ValuesKindDevice ValuesKind = "Device" - // ValuesKindMetric identifies the Metric branch of the union. + // ValuesKindMetric identifies the Metric branch. ValuesKindMetric ValuesKind = "Metric" ) -// Kind returns the discriminator value of the union. +// Kind returns the selected branch. func (u Values) Kind() ValuesKind { return u.kind } @@ -56,7 +56,7 @@ func NewValuesDevice(v alias.Alias) Values { } } -// AsDevice returns the value of the Device branch if set. +// AsDevice returns the value when the Device branch is selected. func (u Values) AsDevice() (_ alias.Alias, ok bool) { if u.kind != ValuesKindDevice { return @@ -64,7 +64,7 @@ func (u Values) AsDevice() (_ alias.Alias, ok bool) { return u.Device, true } -// SetDevice sets the Device branch of the union. +// SetDevice selects the Device branch and stores v. func (u *Values) SetDevice(v alias.Alias) { u.kind = ValuesKindDevice u.Device = v @@ -78,7 +78,7 @@ func NewValuesMetric(v alias.Alias) Values { } } -// AsMetric returns the value of the Metric branch if set. +// AsMetric returns the value when the Metric branch is selected. func (u Values) AsMetric() (_ alias.Alias, ok bool) { if u.kind != ValuesKindMetric { return @@ -86,13 +86,13 @@ func (u Values) AsMetric() (_ alias.Alias, ok bool) { return u.Metric, true } -// SetMetric sets the Metric branch of the union. +// SetMetric selects the Metric branch and stores v. func (u *Values) SetMetric(v alias.Alias) { u.kind = ValuesKindMetric u.Metric = v } -// Validate ensures the union discriminant is valid. +// Validate ensures exactly one valid branch is selected. func (u Values) Validate() error { switch u.kind { case "": @@ -126,7 +126,7 @@ func (u Values) MarshalJSON() ([]byte, error) { case ValuesKindMetric: value = u.Metric default: - return nil, fmt.Errorf("unexpected Values discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected Values kind %q", u.kind) } return json.Marshal(struct { Type string `json:"type"` diff --git a/codegen/service/testdata/golden/service_service-union.go.golden b/codegen/service/testdata/golden/service_service-union.go.golden index 4f505a5b0c..2b3e8a8340 100644 --- a/codegen/service/testdata/golden/service_service-union.go.golden +++ b/codegen/service/testdata/golden/service_service-union.go.golden @@ -34,7 +34,7 @@ type ValuesInt int type ValuesString string -// Values is a sum-type union. +// Values holds exactly one of its branch values. type Values struct { kind ValuesKind Int ValuesInt @@ -43,21 +43,21 @@ type Values struct { Bytes ValuesBytes } -// ValuesKind enumerates the union variants for Values. +// ValuesKind records which Values branch is selected. type ValuesKind string const ( - // ValuesKindInt identifies the Int branch of the union. + // ValuesKindInt identifies the Int branch. ValuesKindInt ValuesKind = "Int" - // ValuesKindString identifies the String branch of the union. + // ValuesKindString identifies the String branch. ValuesKindString ValuesKind = "String" - // ValuesKindBoolean identifies the Boolean branch of the union. + // ValuesKindBoolean identifies the Boolean branch. ValuesKindBoolean ValuesKind = "Boolean" - // ValuesKindBytes identifies the Bytes branch of the union. + // ValuesKindBytes identifies the Bytes branch. ValuesKindBytes ValuesKind = "Bytes" ) -// Kind returns the discriminator value of the union. +// Kind returns the selected branch. func (u Values) Kind() ValuesKind { return u.kind } @@ -70,7 +70,7 @@ func NewValuesInt(v ValuesInt) Values { } } -// AsInt returns the value of the Int branch if set. +// AsInt returns the value when the Int branch is selected. func (u Values) AsInt() (_ ValuesInt, ok bool) { if u.kind != ValuesKindInt { return @@ -78,7 +78,7 @@ func (u Values) AsInt() (_ ValuesInt, ok bool) { return u.Int, true } -// SetInt sets the Int branch of the union. +// SetInt selects the Int branch and stores v. func (u *Values) SetInt(v ValuesInt) { u.kind = ValuesKindInt u.Int = v @@ -92,7 +92,7 @@ func NewValuesString(v ValuesString) Values { } } -// AsString returns the value of the String branch if set. +// AsString returns the value when the String branch is selected. func (u Values) AsString() (_ ValuesString, ok bool) { if u.kind != ValuesKindString { return @@ -100,7 +100,7 @@ func (u Values) AsString() (_ ValuesString, ok bool) { return u.String, true } -// SetString sets the String branch of the union. +// SetString selects the String branch and stores v. func (u *Values) SetString(v ValuesString) { u.kind = ValuesKindString u.String = v @@ -114,7 +114,7 @@ func NewValuesBoolean(v ValuesBoolean) Values { } } -// AsBoolean returns the value of the Boolean branch if set. +// AsBoolean returns the value when the Boolean branch is selected. func (u Values) AsBoolean() (_ ValuesBoolean, ok bool) { if u.kind != ValuesKindBoolean { return @@ -122,7 +122,7 @@ func (u Values) AsBoolean() (_ ValuesBoolean, ok bool) { return u.Boolean, true } -// SetBoolean sets the Boolean branch of the union. +// SetBoolean selects the Boolean branch and stores v. func (u *Values) SetBoolean(v ValuesBoolean) { u.kind = ValuesKindBoolean u.Boolean = v @@ -136,7 +136,7 @@ func NewValuesBytes(v ValuesBytes) Values { } } -// AsBytes returns the value of the Bytes branch if set. +// AsBytes returns the value when the Bytes branch is selected. func (u Values) AsBytes() (_ ValuesBytes, ok bool) { if u.kind != ValuesKindBytes { return @@ -144,13 +144,13 @@ func (u Values) AsBytes() (_ ValuesBytes, ok bool) { return u.Bytes, true } -// SetBytes sets the Bytes branch of the union. +// SetBytes selects the Bytes branch and stores v. func (u *Values) SetBytes(v ValuesBytes) { u.kind = ValuesKindBytes u.Bytes = v } -// Validate ensures the union discriminant is valid. +// Validate ensures exactly one valid branch is selected. func (u Values) Validate() error { switch u.kind { case "": @@ -167,6 +167,9 @@ func (u Values) Validate() error { case ValuesKindBoolean: return nil case ValuesKindBytes: + if u.Bytes == nil { + return goa.MissingFieldError("value", "Values") + } return nil default: return goa.InvalidEnumValueError("type", u.kind, []any{ @@ -196,7 +199,7 @@ func (u Values) MarshalJSON() ([]byte, error) { case ValuesKindBytes: value = u.Bytes default: - return nil, fmt.Errorf("unexpected Values discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected Values kind %q", u.kind) } return json.Marshal(struct { Type string `json:"type"` diff --git a/codegen/service/testdata/interceptors/interceptor-with-external-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-external-payload_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..834ff3c45f --- /dev/null +++ b/codegen/service/testdata/interceptors/interceptor-with-external-payload_interceptor_wrappers.go.golden @@ -0,0 +1,11 @@ + + +// wrapAppendIdentify applies the identify server interceptor to endpoints. +func wrapAppendIdentify(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &identifyAppendServerUnaryInfo{ + identifyAppendInfo: &identifyAppendInfo{rawPayload: req}, + } + return i.Identify(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/interceptor-with-external-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-external-payload_service_interceptors.go.golden new file mode 100644 index 0000000000..5b405a9606 --- /dev/null +++ b/codegen/service/testdata/interceptors/interceptor-with-external-payload_service_interceptors.go.golden @@ -0,0 +1,86 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Identify(ctx context.Context, info IdentifyInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // IdentifyInfo describes the service call currently passed to the interceptor. + IdentifyInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() IdentifyPayload + } + + // IdentifyPayload provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. + IdentifyPayload interface { + RuntimeSessionID() string + } +) + +// Types used to provide information about each service call +type ( + identifyAppendInfo struct { + rawPayload any + } + identifyAppendServerUnaryInfo struct { + *identifyAppendInfo + } + identifyAppendPayload struct { + payload *types.Event + } +) + +// WrapAppendEndpoint wraps the Append endpoint with the server-side +// interceptors defined in the design. +func WrapAppendEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapAppendIdentify(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *identifyAppendInfo) Service() string { + return "InterceptorWithExternalPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *identifyAppendInfo) Method() string { + return "Append" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *identifyAppendInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *identifyAppendServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *identifyAppendInfo) Payload() IdentifyPayload { + return &identifyAppendPayload{payload: info.rawPayload.(*types.Event)} +} + +// Methods that read and write the selected payload and result fields + +func (p *identifyAppendPayload) RuntimeSessionID() string { + return p.payload.RuntimeSessionID +} diff --git a/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..f1df8ec7f9 --- /dev/null +++ b/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_interceptor_wrappers.go.golden @@ -0,0 +1,12 @@ + + +// wrapMethodAuthorization applies the authorization server interceptor to +// endpoints. +func wrapMethodAuthorization(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &authorizationMethodServerUnaryInfo{ + authorizationMethodInfo: &authorizationMethodInfo{rawPayload: req}, + } + return i.Authorization(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_service_interceptors.go.golden new file mode 100644 index 0000000000..f0bcb449f5 --- /dev/null +++ b/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_service_interceptors.go.golden @@ -0,0 +1,86 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Authorization(ctx context.Context, info AuthorizationInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // AuthorizationInfo describes the service call currently passed to the interceptor. + AuthorizationInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() AuthorizationPayload + } + + // AuthorizationPayload provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. + AuthorizationPayload interface { + OrgID() types.UUID + } +) + +// Types used to provide information about each service call +type ( + authorizationMethodInfo struct { + rawPayload any + } + authorizationMethodServerUnaryInfo struct { + *authorizationMethodInfo + } + authorizationMethodPayload struct { + payload *MethodPayload + } +) + +// WrapMethodEndpoint wraps the Method endpoint with the server-side +// interceptors defined in the design. +func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapMethodAuthorization(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *authorizationMethodInfo) Service() string { + return "InterceptorWithExternalReadPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *authorizationMethodInfo) Method() string { + return "Method" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *authorizationMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *authorizationMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *authorizationMethodInfo) Payload() AuthorizationPayload { + return &authorizationMethodPayload{payload: info.rawPayload.(*MethodPayload)} +} + +// Methods that read and write the selected payload and result fields + +func (p *authorizationMethodPayload) OrgID() types.UUID { + return p.payload.OrgID +} diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden index c55718e9a4..f3d5c42641 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodvalidation(endpoint, i) + endpoint = wrapClientMethodValidation(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden index 0cde37225c..d8ecd6b9ad 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden @@ -1,27 +1,21 @@ -// wrapValidationMethod applies the validation server interceptor to endpoints. +// wrapMethodValidation applies the validation server interceptor to endpoints. func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithReadPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodServerUnaryInfo{ + validationMethodInfo: &validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } } -// wrapClientValidationMethod applies the validation client interceptor to +// wrapClientMethodValidation applies the validation client interceptor to // endpoints. func wrapClientMethodValidation(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithReadPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodClientUnaryInfo{ + validationMethodInfo: &validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden index 8510ebab3f..748c957019 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // ValidationInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - ValidationInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // ValidationInfo describes the service call currently passed to the interceptor. + ValidationInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() ValidationPayload } // ValidationPayload provides type-safe access to the method payload. @@ -25,8 +30,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + validationMethodInfo struct { + rawPayload any + } + validationMethodServerUnaryInfo struct { + *validationMethodInfo + } + validationMethodClientUnaryInfo struct { + *validationMethodInfo + } validationMethodPayload struct { payload *MethodPayload } @@ -36,39 +50,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodvalidation(endpoint, i) + endpoint = wrapMethodValidation(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *ValidationInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *validationMethodInfo) Service() string { + return "InterceptorWithReadPayload" } -// Method returns the name of the method handling the request. -func (info *ValidationInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *validationMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *ValidationInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *validationMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *ValidationInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *validationMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *validationMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *ValidationInfo) Payload() ValidationPayload { - return &validationMethodPayload{payload: info.RawPayload().(*MethodPayload)} +// Payload returns this method's payload fields. +func (info *validationMethodInfo) Payload() ValidationPayload { + return &validationMethodPayload{payload: info.rawPayload.(*MethodPayload)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *validationMethodPayload) Name() string { return p.payload.Name diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden index eb23fdc5df..27668ad056 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodcaching(endpoint, i) + endpoint = wrapClientMethodCaching(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden index 431b0789ce..1835333bda 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden @@ -1,26 +1,20 @@ -// wrapCachingMethod applies the caching server interceptor to endpoints. +// wrapMethodCaching applies the caching server interceptor to endpoints. func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithReadResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodServerUnaryInfo{ + cachingMethodInfo: &cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } } -// wrapClientCachingMethod applies the caching client interceptor to endpoints. +// wrapClientMethodCaching applies the caching client interceptor to endpoints. func wrapClientMethodCaching(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithReadResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodClientUnaryInfo{ + cachingMethodInfo: &cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden index 07c3c5c8e9..f3ba5bb029 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // CachingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - CachingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // CachingInfo describes the service call currently passed to the interceptor. + CachingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Result returns the selected fields from the method result. + Result(any) CachingResult } // CachingResult provides type-safe access to the method result. @@ -25,8 +30,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + cachingMethodInfo struct { + rawPayload any + } + cachingMethodServerUnaryInfo struct { + *cachingMethodInfo + } + cachingMethodClientUnaryInfo struct { + *cachingMethodInfo + } cachingMethodResult struct { result *MethodResult } @@ -36,39 +50,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodcaching(endpoint, i) + endpoint = wrapMethodCaching(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *CachingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *cachingMethodInfo) Service() string { + return "InterceptorWithReadResult" } -// Method returns the name of the method handling the request. -func (info *CachingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *cachingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *CachingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *cachingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *CachingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *cachingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *cachingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Result returns a type-safe accessor for the method result. -func (info *CachingInfo) Result(res any) CachingResult { +// Result returns this method's result fields. +func (info *cachingMethodInfo) Result(res any) CachingResult { return &cachingMethodResult{result: res.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *cachingMethodResult) Data() string { return r.result.Data diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden index c55718e9a4..f3d5c42641 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodvalidation(endpoint, i) + endpoint = wrapClientMethodValidation(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden index 8a1d529196..d8ecd6b9ad 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden @@ -1,27 +1,21 @@ -// wrapValidationMethod applies the validation server interceptor to endpoints. +// wrapMethodValidation applies the validation server interceptor to endpoints. func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithReadWritePayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodServerUnaryInfo{ + validationMethodInfo: &validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } } -// wrapClientValidationMethod applies the validation client interceptor to +// wrapClientMethodValidation applies the validation client interceptor to // endpoints. func wrapClientMethodValidation(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithReadWritePayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodClientUnaryInfo{ + validationMethodInfo: &validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden index 1e77d5d8a4..0028d9cea3 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // ValidationInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - ValidationInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // ValidationInfo describes the service call currently passed to the interceptor. + ValidationInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() ValidationPayload } // ValidationPayload provides type-safe access to the method payload. @@ -26,8 +31,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + validationMethodInfo struct { + rawPayload any + } + validationMethodServerUnaryInfo struct { + *validationMethodInfo + } + validationMethodClientUnaryInfo struct { + *validationMethodInfo + } validationMethodPayload struct { payload *MethodPayload } @@ -37,39 +51,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodvalidation(endpoint, i) + endpoint = wrapMethodValidation(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *ValidationInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *validationMethodInfo) Service() string { + return "InterceptorWithReadWritePayload" } -// Method returns the name of the method handling the request. -func (info *ValidationInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *validationMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *ValidationInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *validationMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *ValidationInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *validationMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *validationMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *ValidationInfo) Payload() ValidationPayload { - return &validationMethodPayload{payload: info.RawPayload().(*MethodPayload)} +// Payload returns this method's payload fields. +func (info *validationMethodInfo) Payload() ValidationPayload { + return &validationMethodPayload{payload: info.rawPayload.(*MethodPayload)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *validationMethodPayload) Name() string { return p.payload.Name diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden index eb23fdc5df..27668ad056 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodcaching(endpoint, i) + endpoint = wrapClientMethodCaching(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden index 40be57c66d..1835333bda 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden @@ -1,26 +1,20 @@ -// wrapCachingMethod applies the caching server interceptor to endpoints. +// wrapMethodCaching applies the caching server interceptor to endpoints. func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithReadWriteResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodServerUnaryInfo{ + cachingMethodInfo: &cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } } -// wrapClientCachingMethod applies the caching client interceptor to endpoints. +// wrapClientMethodCaching applies the caching client interceptor to endpoints. func wrapClientMethodCaching(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithReadWriteResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodClientUnaryInfo{ + cachingMethodInfo: &cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden index 5457dfdafe..995ba966d3 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // CachingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - CachingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // CachingInfo describes the service call currently passed to the interceptor. + CachingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Result returns the selected fields from the method result. + Result(any) CachingResult } // CachingResult provides type-safe access to the method result. @@ -26,8 +31,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + cachingMethodInfo struct { + rawPayload any + } + cachingMethodServerUnaryInfo struct { + *cachingMethodInfo + } + cachingMethodClientUnaryInfo struct { + *cachingMethodInfo + } cachingMethodResult struct { result *MethodResult } @@ -37,39 +51,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodcaching(endpoint, i) + endpoint = wrapMethodCaching(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *CachingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *cachingMethodInfo) Service() string { + return "InterceptorWithReadWriteResult" } -// Method returns the name of the method handling the request. -func (info *CachingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *cachingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *CachingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *cachingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *CachingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *cachingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *cachingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Result returns a type-safe accessor for the method result. -func (info *CachingInfo) Result(res any) CachingResult { +// Result returns this method's result fields. +func (info *cachingMethodInfo) Result(res any) CachingResult { return &cachingMethodResult{result: res.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *cachingMethodResult) Data() string { return r.result.Data diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden index c55718e9a4..f3d5c42641 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodvalidation(endpoint, i) + endpoint = wrapClientMethodValidation(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden index d7059393aa..d8ecd6b9ad 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden @@ -1,27 +1,21 @@ -// wrapValidationMethod applies the validation server interceptor to endpoints. +// wrapMethodValidation applies the validation server interceptor to endpoints. func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithWritePayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodServerUnaryInfo{ + validationMethodInfo: &validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } } -// wrapClientValidationMethod applies the validation client interceptor to +// wrapClientMethodValidation applies the validation client interceptor to // endpoints. func wrapClientMethodValidation(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithWritePayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodClientUnaryInfo{ + validationMethodInfo: &validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden index c67700cd58..a49e821e22 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // ValidationInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - ValidationInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // ValidationInfo describes the service call currently passed to the interceptor. + ValidationInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() ValidationPayload } // ValidationPayload provides type-safe access to the method payload. @@ -25,8 +30,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + validationMethodInfo struct { + rawPayload any + } + validationMethodServerUnaryInfo struct { + *validationMethodInfo + } + validationMethodClientUnaryInfo struct { + *validationMethodInfo + } validationMethodPayload struct { payload *MethodPayload } @@ -36,39 +50,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodvalidation(endpoint, i) + endpoint = wrapMethodValidation(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *ValidationInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *validationMethodInfo) Service() string { + return "InterceptorWithWritePayload" } -// Method returns the name of the method handling the request. -func (info *ValidationInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *validationMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *ValidationInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *validationMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *ValidationInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *validationMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *validationMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *ValidationInfo) Payload() ValidationPayload { - return &validationMethodPayload{payload: info.RawPayload().(*MethodPayload)} +// Payload returns this method's payload fields. +func (info *validationMethodInfo) Payload() ValidationPayload { + return &validationMethodPayload{payload: info.rawPayload.(*MethodPayload)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *validationMethodPayload) SetName(v string) { p.payload.Name = v diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden index eb23fdc5df..27668ad056 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodcaching(endpoint, i) + endpoint = wrapClientMethodCaching(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden index 89854d6c95..1835333bda 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden @@ -1,26 +1,20 @@ -// wrapCachingMethod applies the caching server interceptor to endpoints. +// wrapMethodCaching applies the caching server interceptor to endpoints. func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithWriteResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodServerUnaryInfo{ + cachingMethodInfo: &cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } } -// wrapClientCachingMethod applies the caching client interceptor to endpoints. +// wrapClientMethodCaching applies the caching client interceptor to endpoints. func wrapClientMethodCaching(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithWriteResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodClientUnaryInfo{ + cachingMethodInfo: &cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden index 0e7844bbc8..1bc223970c 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // CachingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - CachingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // CachingInfo describes the service call currently passed to the interceptor. + CachingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Result returns the selected fields from the method result. + Result(any) CachingResult } // CachingResult provides type-safe access to the method result. @@ -25,8 +30,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + cachingMethodInfo struct { + rawPayload any + } + cachingMethodServerUnaryInfo struct { + *cachingMethodInfo + } + cachingMethodClientUnaryInfo struct { + *cachingMethodInfo + } cachingMethodResult struct { result *MethodResult } @@ -36,39 +50,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodcaching(endpoint, i) + endpoint = wrapMethodCaching(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *CachingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *cachingMethodInfo) Service() string { + return "InterceptorWithWriteResult" } -// Method returns the name of the method handling the request. -func (info *CachingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *cachingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *CachingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *cachingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *CachingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *cachingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *cachingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Result returns a type-safe accessor for the method result. -func (info *CachingInfo) Result(res any) CachingResult { +// Result returns this method's result fields. +func (info *cachingMethodInfo) Result(res any) CachingResult { return &cachingMethodResult{result: res.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *cachingMethodResult) SetData(v string) { r.result.Data = v diff --git a/codegen/service/testdata/interceptors/leading-initialism-interceptor_client_interceptors.go.golden b/codegen/service/testdata/interceptors/leading-initialism-interceptor_client_interceptors.go.golden new file mode 100644 index 0000000000..8a060fcb93 --- /dev/null +++ b/codegen/service/testdata/interceptors/leading-initialism-interceptor_client_interceptors.go.golden @@ -0,0 +1,16 @@ +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors interface { + JWTAuth(ctx context.Context, info JWTAuthInfo, next goa.Endpoint) (any, error) +} + +// WrapGetInfoClientEndpoint wraps the GetInfo endpoint with the client +// interceptors defined in the design. +func WrapGetInfoClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapClientGetInfoJWTAuth(endpoint, i) + } + return endpoint +} diff --git a/codegen/service/testdata/interceptors/leading-initialism-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/leading-initialism-interceptor_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..1ef244dd89 --- /dev/null +++ b/codegen/service/testdata/interceptors/leading-initialism-interceptor_interceptor_wrappers.go.golden @@ -0,0 +1,21 @@ + + +// wrapGetInfoJWTAuth applies the JWTAuth server interceptor to endpoints. +func wrapGetInfoJWTAuth(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &jwtAuthGetInfoServerUnaryInfo{ + jwtAuthGetInfoInfo: &jwtAuthGetInfoInfo{rawPayload: req}, + } + return i.JWTAuth(ctx, info, endpoint) + } +} + +// wrapClientGetInfoJWTAuth applies the JWTAuth client interceptor to endpoints. +func wrapClientGetInfoJWTAuth(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &jwtAuthGetInfoClientUnaryInfo{ + jwtAuthGetInfoInfo: &jwtAuthGetInfoInfo{rawPayload: req}, + } + return i.JWTAuth(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/leading-initialism-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/leading-initialism-interceptor_service_interceptors.go.golden new file mode 100644 index 0000000000..0bf6c0af3e --- /dev/null +++ b/codegen/service/testdata/interceptors/leading-initialism-interceptor_service_interceptors.go.golden @@ -0,0 +1,71 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + JWTAuth(ctx context.Context, info JWTAuthInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // JWTAuthInfo describes the service call currently passed to the interceptor. + JWTAuthInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + jwtAuthGetInfoInfo struct { + rawPayload any + } + jwtAuthGetInfoServerUnaryInfo struct { + *jwtAuthGetInfoInfo + } + jwtAuthGetInfoClientUnaryInfo struct { + *jwtAuthGetInfoInfo + } +) + +// WrapGetInfoEndpoint wraps the GetInfo endpoint with the server-side +// interceptors defined in the design. +func WrapGetInfoEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapGetInfoJWTAuth(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *jwtAuthGetInfoInfo) Service() string { + return "LeadingInitialismInterceptor" +} + +// Method returns the method selected for this interceptor call. +func (info *jwtAuthGetInfoInfo) Method() string { + return "GetInfo" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *jwtAuthGetInfoInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *jwtAuthGetInfoServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *jwtAuthGetInfoClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_client_interceptors.go.golden new file mode 100644 index 0000000000..b9208fde0f --- /dev/null +++ b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_client_interceptors.go.golden @@ -0,0 +1,16 @@ +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors interface { + Identify(ctx context.Context, info IdentifyInfo, next goa.Endpoint) (any, error) +} + +// WrapClientMethodClientEndpoint wraps the ClientMethod endpoint with the +// client interceptors defined in the design. +func WrapClientMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapClientClientMethodIdentify(endpoint, i) + } + return endpoint +} diff --git a/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..98ae184bc6 --- /dev/null +++ b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_interceptor_wrappers.go.golden @@ -0,0 +1,23 @@ + + +// wrapServerMethodIdentify applies the identify server interceptor to +// endpoints. +func wrapServerMethodIdentify(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &identifyServerMethodServerUnaryInfo{ + identifyServerMethodInfo: &identifyServerMethodInfo{rawPayload: req}, + } + return i.Identify(ctx, info, endpoint) + } +} + +// wrapClientClientMethodIdentify applies the identify client interceptor to +// endpoints. +func wrapClientClientMethodIdentify(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &identifyClientMethodClientUnaryInfo{ + identifyClientMethodInfo: &identifyClientMethodInfo{rawPayload: req}, + } + return i.Identify(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_service_interceptors.go.golden new file mode 100644 index 0000000000..289aa32444 --- /dev/null +++ b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_service_interceptors.go.golden @@ -0,0 +1,123 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Identify(ctx context.Context, info IdentifyInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // IdentifyInfo describes the service call currently passed to the interceptor. + IdentifyInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() IdentifyPayload + } + + // IdentifyPayload provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. + IdentifyPayload interface { + RuntimeSessionID() string + } +) + +// Types used to provide information about each service call +type ( + identifyServerMethodInfo struct { + rawPayload any + } + identifyServerMethodServerUnaryInfo struct { + *identifyServerMethodInfo + } + identifyClientMethodInfo struct { + rawPayload any + } + identifyClientMethodClientUnaryInfo struct { + *identifyClientMethodInfo + } + identifyServerMethodPayload struct { + payload *ServerMethodPayload + } + identifyClientMethodPayload struct { + payload *types.Event + } +) + +// WrapServerMethodEndpoint wraps the ServerMethod endpoint with the +// server-side interceptors defined in the design. +func WrapServerMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapServerMethodIdentify(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *identifyServerMethodInfo) Service() string { + return "MergedInterceptorsWithExternalClientPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *identifyServerMethodInfo) Method() string { + return "ServerMethod" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *identifyServerMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *identifyServerMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *identifyServerMethodInfo) Payload() IdentifyPayload { + return &identifyServerMethodPayload{payload: info.rawPayload.(*ServerMethodPayload)} +} + +// Service returns the service selected for this interceptor call. +func (info *identifyClientMethodInfo) Service() string { + return "MergedInterceptorsWithExternalClientPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *identifyClientMethodInfo) Method() string { + return "ClientMethod" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *identifyClientMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a client endpoint call. +func (info *identifyClientMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *identifyClientMethodInfo) Payload() IdentifyPayload { + return &identifyClientMethodPayload{payload: info.rawPayload.(*types.Event)} +} + +// Methods that read and write the selected payload and result fields + +func (p *identifyServerMethodPayload) RuntimeSessionID() string { + return p.payload.RuntimeSessionID +} +func (p *identifyClientMethodPayload) RuntimeSessionID() string { + return p.payload.RuntimeSessionID +} diff --git a/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_client_interceptors.go.golden new file mode 100644 index 0000000000..fb28fa6e29 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_client_interceptors.go.golden @@ -0,0 +1,86 @@ +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors interface { + Authorization(ctx context.Context, info AuthorizationInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // AuthorizationInfo describes the service call currently passed to the interceptor. + AuthorizationInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() AuthorizationPayload + } + + // AuthorizationPayload provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. + AuthorizationPayload interface { + OrgID() types.UUID + } +) + +// Types used to provide information about each service call +type ( + authorizationMethodInfo struct { + rawPayload any + } + authorizationMethodClientUnaryInfo struct { + *authorizationMethodInfo + } + authorizationMethodPayload struct { + payload *MethodPayload + } +) + +// WrapMethodClientEndpoint wraps the Method endpoint with the client +// interceptors defined in the design. +func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapClientMethodAuthorization(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *authorizationMethodInfo) Service() string { + return "MixedInterceptorsWithExternalClientPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *authorizationMethodInfo) Method() string { + return "Method" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *authorizationMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a client endpoint call. +func (info *authorizationMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *authorizationMethodInfo) Payload() AuthorizationPayload { + return &authorizationMethodPayload{payload: info.rawPayload.(*MethodPayload)} +} + +// Methods that read and write the selected payload and result fields + +func (p *authorizationMethodPayload) OrgID() types.UUID { + return p.payload.OrgID +} diff --git a/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..78200bfb17 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_interceptor_wrappers.go.golden @@ -0,0 +1,22 @@ + + +// wrapMethodLogging applies the logging server interceptor to endpoints. +func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, + } + return i.Logging(ctx, info, endpoint) + } +} + +// wrapClientMethodAuthorization applies the authorization client interceptor +// to endpoints. +func wrapClientMethodAuthorization(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &authorizationMethodClientUnaryInfo{ + authorizationMethodInfo: &authorizationMethodInfo{rawPayload: req}, + } + return i.Authorization(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_service_interceptors.go.golden new file mode 100644 index 0000000000..5b2948932f --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_service_interceptors.go.golden @@ -0,0 +1,63 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } +) + +// WrapMethodEndpoint wraps the Method endpoint with the server-side +// interceptors defined in the design. +func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapMethodLogging(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "MixedInterceptorsWithExternalClientPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_client_interceptors.go.golden b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_client_interceptors.go.golden new file mode 100644 index 0000000000..7f47a35c66 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_client_interceptors.go.golden @@ -0,0 +1,16 @@ +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors interface { + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) +} + +// WrapMethodClientEndpoint wraps the Method endpoint with the client +// interceptors defined in the design. +func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapClientMethodLogging(endpoint, i) + } + return endpoint +} diff --git a/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..0335dd085c --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_interceptor_wrappers.go.golden @@ -0,0 +1,104 @@ + + +// wrappedMethodServerStream is a server interceptor wrapper for the +// MethodServerStream stream. +type wrappedMethodServerStream struct { + ctx context.Context + sendWithContext func(context.Context, *Event) error + stream MethodServerStream +} + +// wrappedMethodClientStream is a client interceptor wrapper for the +// MethodClientStream stream. +type wrappedMethodClientStream struct { + ctx context.Context + recvWithContext func(context.Context) (*Event, error) + stream MethodClientStream +} + +// wrapMethodLogging applies the logging server interceptor to endpoints. +func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + stream := req.(*MethodEndpointInput).Stream + req.(*MethodEndpointInput).Stream = &wrappedMethodServerStream{ + ctx: ctx, + sendWithContext: func(ctx context.Context, req *Event) error { + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, + } + _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { + castReq, _ := req.(*Event) + return nil, stream.SendWithContext(ctx, castReq) + }) + return err + }, + stream: stream, + } + return endpoint(ctx, req) + } +} + +// wrapClientMethodLogging applies the logging client interceptor to endpoints. +func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + res, err := endpoint(ctx, req) + if err != nil { + return res, err + } + stream := res.(MethodClientStream) + return &wrappedMethodClientStream{ + ctx: ctx, + recvWithContext: func(ctx context.Context) (*Event, error) { + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: &loggingMethodInfo{}, + } + res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { + return stream.RecvWithContext(ctx) + }) + castRes, _ := res.(*Event) + return castRes, err + }, + stream: stream, + }, nil + } +} + +// Unwrap returns the underlying stream type. +func (w *wrappedMethodServerStream) Unwrap() any { + return w.stream +} + +// Send streams instances of "MethodServerStream" after executing the applied +// interceptor. +func (w *wrappedMethodServerStream) Send(v *Event) error { + return w.SendWithContext(w.ctx, v) +} + +// SendWithContext streams instances of "MethodServerStream" after executing +// the applied interceptor with context. +func (w *wrappedMethodServerStream) SendWithContext(ctx context.Context, v *Event) error { + if w.sendWithContext == nil { + return w.stream.SendWithContext(ctx, v) + } + return w.sendWithContext(ctx, v) +} + +// Close closes the stream. +func (w *wrappedMethodServerStream) Close() error { + return w.stream.Close() +} + +// Recv reads instances of "MethodClientStream" from the stream after executing +// the applied interceptor. +func (w *wrappedMethodClientStream) Recv() (*Event, error) { + return w.RecvWithContext(w.ctx) +} + +// RecvWithContext reads instances of "MethodClientStream" from the stream +// after executing the applied interceptor with context. +func (w *wrappedMethodClientStream) RecvWithContext(ctx context.Context) (*Event, error) { + if w.recvWithContext == nil { + return w.stream.RecvWithContext(ctx) + } + return w.recvWithContext(ctx) +} diff --git a/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_service_interceptors.go.golden b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_service_interceptors.go.golden new file mode 100644 index 0000000000..4cc6602cd1 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_service_interceptors.go.golden @@ -0,0 +1,105 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // ClientStreamingResult returns selected fields from the incoming stream result. + ClientStreamingResult(any) LoggingStreamingResult + // ServerStreamingResult returns selected fields from the outgoing stream result. + ServerStreamingResult() LoggingStreamingResult + } + + // LoggingStreamingResult provides type-safe access to the method streaming result. + // It allows reading and writing specific fields of the streaming result as defined + // in the design. + LoggingStreamingResult interface { + Message() string + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodStreamingSendInfo struct { + *loggingMethodInfo + } + loggingMethodStreamingRecvInfo struct { + *loggingMethodInfo + } + loggingMethodStreamingResult struct { + result *Event + } +) + +// WrapMethodEndpoint wraps the Method endpoint with the server-side +// interceptors defined in the design. +func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapMethodLogging(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "MixedResultStreamingInterceptors" +} + +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a stream send. +func (info *loggingMethodStreamingSendInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend +} + +// CallType reports that this is a stream receive. +func (info *loggingMethodStreamingRecvInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv +} + +// ClientStreamingResult returns this method's incoming streaming result fields. +func (info *loggingMethodInfo) ClientStreamingResult(result any) LoggingStreamingResult { + return &loggingMethodStreamingResult{result: result.(*Event)} +} + +// ServerStreamingResult returns this method's outgoing streaming result fields. +func (info *loggingMethodInfo) ServerStreamingResult() LoggingStreamingResult { + return &loggingMethodStreamingResult{result: info.rawPayload.(*Event)} +} + +// Methods that read and write the selected payload and result fields + +func (r *loggingMethodStreamingResult) Message() string { + if r.result.Message == nil { + var zero string + return zero + } + return *r.result.Message +} diff --git a/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden b/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden index 449f55aa49..3aa5aa8505 100644 --- a/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden @@ -3,78 +3,100 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Test2(ctx context.Context, info *Test2Info, next goa.Endpoint) (any, error) - Test4(ctx context.Context, info *Test4Info, next goa.Endpoint) (any, error) + Test2(ctx context.Context, info Test2Info, next goa.Endpoint) (any, error) + Test4(ctx context.Context, info Test4Info, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // Test2Info provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - Test2Info struct { - service string - method string - callType goa.InterceptorCallType + // Test2Info describes the service call currently passed to the interceptor. + Test2Info interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } + // Test4Info describes the service call currently passed to the interceptor. + Test4Info interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + test2MethodInfo struct { rawPayload any } - // Test4Info provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - Test4Info struct { - service string - method string - callType goa.InterceptorCallType + test2MethodClientUnaryInfo struct { + *test2MethodInfo + } + test4MethodInfo struct { rawPayload any } + test4MethodClientUnaryInfo struct { + *test4MethodInfo + } ) // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodtest2(endpoint, i) - endpoint = wrapClientMethodtest4(endpoint, i) + endpoint = wrapClientMethodTest2(endpoint, i) + endpoint = wrapClientMethodTest4(endpoint, i) } return endpoint } -// Public accessor methods for Info types - -// Service returns the name of the service handling the request. -func (info *Test2Info) Service() string { - return info.service -} +// Methods that provide information about each service call -// Method returns the name of the method handling the request. -func (info *Test2Info) Method() string { - return info.method +// Service returns the service selected for this interceptor call. +func (info *test2MethodInfo) Service() string { + return "MultipleInterceptorsService" } -// CallType returns the type of call the interceptor is handling. -func (info *Test2Info) CallType() goa.InterceptorCallType { - return info.callType +// Method returns the method selected for this interceptor call. +func (info *test2MethodInfo) Method() string { + return "Method" } -// RawPayload returns the raw payload of the request. -func (info *Test2Info) RawPayload() any { +// RawPayload returns the payload supplied for this interceptor call. +func (info *test2MethodInfo) RawPayload() any { return info.rawPayload } -// Service returns the name of the service handling the request. -func (info *Test4Info) Service() string { - return info.service +// CallType reports that this is a client endpoint call. +func (info *test2MethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Method returns the name of the method handling the request. -func (info *Test4Info) Method() string { - return info.method +// Service returns the service selected for this interceptor call. +func (info *test4MethodInfo) Service() string { + return "MultipleInterceptorsService" } -// CallType returns the type of call the interceptor is handling. -func (info *Test4Info) CallType() goa.InterceptorCallType { - return info.callType +// Method returns the method selected for this interceptor call. +func (info *test4MethodInfo) Method() string { + return "Method" } -// RawPayload returns the raw payload of the request. -func (info *Test4Info) RawPayload() any { +// RawPayload returns the payload supplied for this interceptor call. +func (info *test4MethodInfo) RawPayload() any { return info.rawPayload } + +// CallType reports that this is a client endpoint call. +func (info *test4MethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden index cfabf2905f..0cb4c3721e 100644 --- a/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden @@ -1,52 +1,40 @@ -// wrapTestMethod applies the test server interceptor to endpoints. +// wrapMethodTest applies the test server interceptor to endpoints. func wrapMethodTest(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &TestInfo{ - service: "MultipleInterceptorsService", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &testMethodServerUnaryInfo{ + testMethodInfo: &testMethodInfo{rawPayload: req}, } return i.Test(ctx, info, endpoint) } } -// wrapTest3Method applies the test3 server interceptor to endpoints. +// wrapMethodTest3 applies the test3 server interceptor to endpoints. func wrapMethodTest3(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &Test3Info{ - service: "MultipleInterceptorsService", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &test3MethodServerUnaryInfo{ + test3MethodInfo: &test3MethodInfo{rawPayload: req}, } return i.Test3(ctx, info, endpoint) } } -// wrapClientTest2Method applies the test2 client interceptor to endpoints. +// wrapClientMethodTest2 applies the test2 client interceptor to endpoints. func wrapClientMethodTest2(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &Test2Info{ - service: "MultipleInterceptorsService", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &test2MethodClientUnaryInfo{ + test2MethodInfo: &test2MethodInfo{rawPayload: req}, } return i.Test2(ctx, info, endpoint) } } -// wrapClientTest4Method applies the test4 client interceptor to endpoints. +// wrapClientMethodTest4 applies the test4 client interceptor to endpoints. func wrapClientMethodTest4(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &Test4Info{ - service: "MultipleInterceptorsService", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &test4MethodClientUnaryInfo{ + test4MethodInfo: &test4MethodInfo{rawPayload: req}, } return i.Test4(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden b/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden index 46327a5a20..06a5cd579e 100644 --- a/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden @@ -3,78 +3,100 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Test(ctx context.Context, info *TestInfo, next goa.Endpoint) (any, error) - Test3(ctx context.Context, info *Test3Info, next goa.Endpoint) (any, error) + Test(ctx context.Context, info TestInfo, next goa.Endpoint) (any, error) + Test3(ctx context.Context, info Test3Info, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // TestInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - TestInfo struct { - service string - method string - callType goa.InterceptorCallType + // TestInfo describes the service call currently passed to the interceptor. + TestInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } + // Test3Info describes the service call currently passed to the interceptor. + Test3Info interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + testMethodInfo struct { rawPayload any } - // Test3Info provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - Test3Info struct { - service string - method string - callType goa.InterceptorCallType + testMethodServerUnaryInfo struct { + *testMethodInfo + } + test3MethodInfo struct { rawPayload any } + test3MethodServerUnaryInfo struct { + *test3MethodInfo + } ) // WrapMethodEndpoint wraps the Method endpoint with the server-side // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodtest(endpoint, i) - endpoint = wrapMethodtest3(endpoint, i) + endpoint = wrapMethodTest(endpoint, i) + endpoint = wrapMethodTest3(endpoint, i) } return endpoint } -// Public accessor methods for Info types - -// Service returns the name of the service handling the request. -func (info *TestInfo) Service() string { - return info.service -} +// Methods that provide information about each service call -// Method returns the name of the method handling the request. -func (info *TestInfo) Method() string { - return info.method +// Service returns the service selected for this interceptor call. +func (info *testMethodInfo) Service() string { + return "MultipleInterceptorsService" } -// CallType returns the type of call the interceptor is handling. -func (info *TestInfo) CallType() goa.InterceptorCallType { - return info.callType +// Method returns the method selected for this interceptor call. +func (info *testMethodInfo) Method() string { + return "Method" } -// RawPayload returns the raw payload of the request. -func (info *TestInfo) RawPayload() any { +// RawPayload returns the payload supplied for this interceptor call. +func (info *testMethodInfo) RawPayload() any { return info.rawPayload } -// Service returns the name of the service handling the request. -func (info *Test3Info) Service() string { - return info.service +// CallType reports that this is a server endpoint call. +func (info *testMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Method returns the name of the method handling the request. -func (info *Test3Info) Method() string { - return info.method +// Service returns the service selected for this interceptor call. +func (info *test3MethodInfo) Service() string { + return "MultipleInterceptorsService" } -// CallType returns the type of call the interceptor is handling. -func (info *Test3Info) CallType() goa.InterceptorCallType { - return info.callType +// Method returns the method selected for this interceptor call. +func (info *test3MethodInfo) Method() string { + return "Method" } -// RawPayload returns the raw payload of the request. -func (info *Test3Info) RawPayload() any { +// RawPayload returns the payload supplied for this interceptor call. +func (info *test3MethodInfo) RawPayload() any { return info.rawPayload } + +// CallType reports that this is a server endpoint call. +func (info *test3MethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden index 498cc404ca..f9c6e5766a 100644 --- a/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden @@ -1,26 +1,20 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleAPIServerInterceptor", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } } -// wrapLoggingMethod2 applies the logging server interceptor to endpoints. +// wrapMethod2Logging applies the logging server interceptor to endpoints. func wrapMethod2Logging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleAPIServerInterceptor", - method: "Method2", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethod2ServerUnaryInfo{ + loggingMethod2Info: &loggingMethod2Info{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden index 4e28b05e7a..444cb0dcd0 100644 --- a/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden @@ -3,26 +3,45 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { rawPayload any } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } + loggingMethod2Info struct { + rawPayload any + } + loggingMethod2ServerUnaryInfo struct { + *loggingMethod2Info + } ) // WrapMethodEndpoint wraps the Method endpoint with the server-side // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } @@ -31,29 +50,49 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin // interceptors defined in the design. func WrapMethod2Endpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethod2logging(endpoint, i) + endpoint = wrapMethod2Logging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "SingleAPIServerInterceptor" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Service returns the service selected for this interceptor call. +func (info *loggingMethod2Info) Service() string { + return "SingleAPIServerInterceptor" +} + +// Method returns the method selected for this interceptor call. +func (info *loggingMethod2Info) Method() string { + return "Method2" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethod2Info) RawPayload() any { return info.rawPayload } + +// CallType reports that this is a server endpoint call. +func (info *loggingMethod2ServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden b/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden index 8bc74da506..2aeaec7b85 100644 --- a/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden @@ -3,48 +3,61 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Tracing(ctx context.Context, info *TracingInfo, next goa.Endpoint) (any, error) + Tracing(ctx context.Context, info TracingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // TracingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - TracingInfo struct { - service string - method string - callType goa.InterceptorCallType + // TracingInfo describes the service call currently passed to the interceptor. + TracingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + tracingMethodInfo struct { rawPayload any } + tracingMethodClientUnaryInfo struct { + *tracingMethodInfo + } ) // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodtracing(endpoint, i) + endpoint = wrapClientMethodTracing(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *TracingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *tracingMethodInfo) Service() string { + return "SingleClientInterceptor" } -// Method returns the name of the method handling the request. -func (info *TracingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *tracingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *TracingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *tracingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *TracingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a client endpoint call. +func (info *tracingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } diff --git a/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden index b72e20a2e2..fc83673e5a 100644 --- a/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden @@ -1,13 +1,10 @@ -// wrapClientTracingMethod applies the tracing client interceptor to endpoints. +// wrapClientMethodTracing applies the tracing client interceptor to endpoints. func wrapClientMethodTracing(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &TracingInfo{ - service: "SingleClientInterceptor", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &tracingMethodClientUnaryInfo{ + tracingMethodInfo: &tracingMethodInfo{rawPayload: req}, } return i.Tracing(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden index 58c5f96d45..2ead4ac780 100644 --- a/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden @@ -1,13 +1,10 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleMethodServerInterceptor", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden index 73b5303882..d5749cc45a 100644 --- a/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden @@ -3,48 +3,61 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { rawPayload any } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } ) // WrapMethodEndpoint wraps the Method endpoint with the server-side // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "SingleMethodServerInterceptor" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } diff --git a/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden index a73b41c30c..f9c6e5766a 100644 --- a/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden @@ -1,26 +1,20 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleServerInterceptor", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } } -// wrapLoggingMethod2 applies the logging server interceptor to endpoints. +// wrapMethod2Logging applies the logging server interceptor to endpoints. func wrapMethod2Logging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleServerInterceptor", - method: "Method2", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethod2ServerUnaryInfo{ + loggingMethod2Info: &loggingMethod2Info{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden index 4e28b05e7a..46f8d24697 100644 --- a/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden @@ -3,26 +3,45 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { rawPayload any } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } + loggingMethod2Info struct { + rawPayload any + } + loggingMethod2ServerUnaryInfo struct { + *loggingMethod2Info + } ) // WrapMethodEndpoint wraps the Method endpoint with the server-side // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } @@ -31,29 +50,49 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin // interceptors defined in the design. func WrapMethod2Endpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethod2logging(endpoint, i) + endpoint = wrapMethod2Logging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "SingleServerInterceptor" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Service returns the service selected for this interceptor call. +func (info *loggingMethod2Info) Service() string { + return "SingleServerInterceptor" +} + +// Method returns the method selected for this interceptor call. +func (info *loggingMethod2Info) Method() string { + return "Method2" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethod2Info) RawPayload() any { return info.rawPayload } + +// CallType reports that this is a server endpoint call. +func (info *loggingMethod2ServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden index 46dd4655d2..7f47a35c66 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodlogging(endpoint, i) + endpoint = wrapClientMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden index fe7974931a..484d5342fe 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden @@ -16,17 +16,15 @@ type wrappedMethodClientStream struct { stream MethodClientStream } -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { stream := req.(*MethodEndpointInput).Stream req.(*MethodEndpointInput).Stream = &wrappedMethodServerStream{ ctx: ctx, recvWithContext: func(ctx context.Context) (*MethodStreamingPayload, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload", - method: "Method", - callType: goa.InterceptorStreamingRecv, + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: &loggingMethodInfo{}, } res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.RecvWithContext(ctx) @@ -36,24 +34,18 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint }, stream: stream, } - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } } -// wrapClientLoggingMethod applies the logging client interceptor to endpoints. +// wrapClientMethodLogging applies the logging client interceptor to endpoints. func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodClientUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } res, err := i.Logging(ctx, info, endpoint) if err != nil { @@ -63,11 +55,8 @@ func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.En return &wrappedMethodClientStream{ ctx: ctx, sendWithContext: func(ctx context.Context, req *MethodStreamingPayload) error { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload", - method: "Method", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.(*MethodStreamingPayload) diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden index 47a6372ee4..4460790930 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden @@ -3,18 +3,27 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() LoggingPayload + // ClientStreamingPayload returns selected fields from the outgoing stream payload. + ClientStreamingPayload() LoggingStreamingPayload + // ServerStreamingPayload returns selected fields from the incoming stream payload. + ServerStreamingPayload(any) LoggingStreamingPayload } // LoggingPayload provides type-safe access to the method payload. @@ -32,8 +41,23 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } + loggingMethodClientUnaryInfo struct { + *loggingMethodInfo + } + loggingMethodStreamingSendInfo struct { + *loggingMethodInfo + } + loggingMethodStreamingRecvInfo struct { + *loggingMethodInfo + } loggingMethodPayload struct { payload *MethodPayload } @@ -46,54 +70,69 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *LoggingInfo) Payload() LoggingPayload { - switch pay := info.RawPayload().(type) { - case *MethodEndpointInput: - return &loggingMethodPayload{payload: pay.Payload} - default: - return &loggingMethodPayload{payload: pay.(*MethodPayload)} - } +// CallType reports that this is a client endpoint call. +func (info *loggingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a stream send. +func (info *loggingMethodStreamingSendInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend +} + +// CallType reports that this is a stream receive. +func (info *loggingMethodStreamingRecvInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv +} + +// Payload returns this method's payload fields. +func (info *loggingMethodInfo) Payload() LoggingPayload { + return &loggingMethodPayload{payload: info.rawPayload.(*MethodPayload)} +} + +// Payload returns this server method's payload fields. +func (info *loggingMethodServerUnaryInfo) Payload() LoggingPayload { + return &loggingMethodPayload{payload: info.rawPayload.(*MethodEndpointInput).Payload} } -// ClientStreamingPayload returns a type-safe accessor for the method streaming payload for a client-side interceptor. -func (info *LoggingInfo) ClientStreamingPayload() LoggingStreamingPayload { - return &loggingMethodStreamingPayload{payload: info.RawPayload().(*MethodStreamingPayload)} +// ClientStreamingPayload returns this method's outgoing streaming payload fields. +func (info *loggingMethodInfo) ClientStreamingPayload() LoggingStreamingPayload { + return &loggingMethodStreamingPayload{payload: info.rawPayload.(*MethodStreamingPayload)} } -// ServerStreamingPayload returns a type-safe accessor for the method streaming payload for a server-side interceptor. -func (info *LoggingInfo) ServerStreamingPayload(pay any) LoggingStreamingPayload { - return &loggingMethodStreamingPayload{payload: pay.(*MethodStreamingPayload)} +// ServerStreamingPayload returns this method's incoming streaming payload fields. +func (info *loggingMethodInfo) ServerStreamingPayload(payload any) LoggingStreamingPayload { + return &loggingMethodStreamingPayload{payload: payload.(*MethodStreamingPayload)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *loggingMethodPayload) Chunk() string { if p.payload.Chunk == nil { diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden index b4f94826cd..2ead4ac780 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden @@ -1,13 +1,10 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden index 031813f303..2d06a3fc3c 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Payload returns the selected fields from the method payload. + Payload() LoggingPayload } // LoggingPayload provides type-safe access to the method payload. @@ -25,8 +30,14 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } loggingMethodPayload struct { payload *MethodPayload } @@ -36,44 +47,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptorsWithReadPayload" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *LoggingInfo) Payload() LoggingPayload { - switch pay := info.RawPayload().(type) { - case *MethodEndpointInput: - return &loggingMethodPayload{payload: pay.Payload} - default: - return &loggingMethodPayload{payload: pay.(*MethodPayload)} - } +// Payload returns this method's payload fields. +func (info *loggingMethodInfo) Payload() LoggingPayload { + return &loggingMethodPayload{payload: info.rawPayload.(*MethodPayload)} +} + +// Payload returns this server method's payload fields. +func (info *loggingMethodServerUnaryInfo) Payload() LoggingPayload { + return &loggingMethodPayload{payload: info.rawPayload.(*MethodEndpointInput).Payload} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *loggingMethodPayload) Initial() string { if p.payload.Initial == nil { diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden index b6e8f62cc2..2ead4ac780 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden @@ -1,13 +1,10 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden index e1b86b82ab..d5e604ff2a 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden @@ -3,18 +3,23 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // Result returns the selected fields from the method result. + Result(any) LoggingResult } // LoggingResult provides type-safe access to the method result. @@ -25,8 +30,14 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodServerUnaryInfo struct { + *loggingMethodInfo + } loggingMethodResult struct { result *MethodResult } @@ -36,39 +47,39 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptorsWithReadResult" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Result returns a type-safe accessor for the method result. -func (info *LoggingInfo) Result(res any) LoggingResult { +// Result returns this method's result fields. +func (info *loggingMethodInfo) Result(res any) LoggingResult { return &loggingMethodResult{result: res.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *loggingMethodResult) Data() string { if r.result.Data == nil { diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden index 46dd4655d2..7f47a35c66 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodlogging(endpoint, i) + endpoint = wrapClientMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden index ecb540af91..f37b954c22 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden @@ -16,18 +16,15 @@ type wrappedMethodClientStream struct { stream MethodClientStream } -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { stream := req.(*MethodEndpointInput).Stream req.(*MethodEndpointInput).Stream = &wrappedMethodServerStream{ ctx: ctx, sendWithContext: func(ctx context.Context, req *MethodResult) error { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadStreamingResult", - method: "Method", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.(*MethodResult) @@ -41,7 +38,7 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint } } -// wrapClientLoggingMethod applies the logging client interceptor to endpoints. +// wrapClientMethodLogging applies the logging client interceptor to endpoints. func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { res, err := endpoint(ctx, req) @@ -52,10 +49,8 @@ func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.En return &wrappedMethodClientStream{ ctx: ctx, recvWithContext: func(ctx context.Context) (*MethodResult, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadStreamingResult", - method: "Method", - callType: goa.InterceptorStreamingRecv, + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: &loggingMethodInfo{}, } res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.RecvWithContext(ctx) diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden index af6d942fef..1bf1f623ab 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden @@ -3,18 +3,25 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // ClientStreamingResult returns selected fields from the incoming stream result. + ClientStreamingResult(any) LoggingStreamingResult + // ServerStreamingResult returns selected fields from the outgoing stream result. + ServerStreamingResult() LoggingStreamingResult } // LoggingStreamingResult provides type-safe access to the method streaming result. @@ -25,8 +32,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodStreamingSendInfo struct { + *loggingMethodInfo + } + loggingMethodStreamingRecvInfo struct { + *loggingMethodInfo + } loggingMethodStreamingResult struct { result *MethodResult } @@ -36,44 +52,49 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptorsWithReadStreamingResult" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a stream send. +func (info *loggingMethodStreamingSendInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend +} + +// CallType reports that this is a stream receive. +func (info *loggingMethodStreamingRecvInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv } -// ClientStreamingResult returns a type-safe accessor for the method streaming result for a client-side interceptor. -func (info *LoggingInfo) ClientStreamingResult(res any) LoggingStreamingResult { - return &loggingMethodStreamingResult{result: res.(*MethodResult)} +// ClientStreamingResult returns this method's incoming streaming result fields. +func (info *loggingMethodInfo) ClientStreamingResult(result any) LoggingStreamingResult { + return &loggingMethodStreamingResult{result: result.(*MethodResult)} } -// ServerStreamingResult returns a type-safe accessor for the method streaming result for a server-side interceptor. -func (info *LoggingInfo) ServerStreamingResult() LoggingStreamingResult { - return &loggingMethodStreamingResult{result: info.RawPayload().(*MethodResult)} +// ServerStreamingResult returns this method's outgoing streaming result fields. +func (info *loggingMethodInfo) ServerStreamingResult() LoggingStreamingResult { + return &loggingMethodStreamingResult{result: info.rawPayload.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *loggingMethodStreamingResult) Data() string { if r.result.Data == nil { diff --git a/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden index 46dd4655d2..7f47a35c66 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodlogging(endpoint, i) + endpoint = wrapClientMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden index dfd5cedeac..1ab9f190fc 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden @@ -18,18 +18,15 @@ type wrappedMethodClientStream struct { stream MethodClientStream } -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { stream := req.(*MethodEndpointInput).Stream req.(*MethodEndpointInput).Stream = &wrappedMethodServerStream{ ctx: ctx, sendWithContext: func(ctx context.Context, req *MethodResult) error { - info := &LoggingInfo{ - service: "StreamingInterceptors", - method: "Method", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.(*MethodResult) @@ -38,10 +35,8 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint return err }, recvWithContext: func(ctx context.Context) (*MethodStreamingPayload, error) { - info := &LoggingInfo{ - service: "StreamingInterceptors", - method: "Method", - callType: goa.InterceptorStreamingRecv, + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: &loggingMethodInfo{}, } res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.RecvWithContext(ctx) @@ -55,7 +50,7 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint } } -// wrapClientLoggingMethod applies the logging client interceptor to endpoints. +// wrapClientMethodLogging applies the logging client interceptor to endpoints. func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { res, err := endpoint(ctx, req) @@ -66,11 +61,8 @@ func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.En return &wrappedMethodClientStream{ ctx: ctx, sendWithContext: func(ctx context.Context, req *MethodStreamingPayload) error { - info := &LoggingInfo{ - service: "StreamingInterceptors", - method: "Method", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: &loggingMethodInfo{rawPayload: req}, } _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.(*MethodStreamingPayload) @@ -79,10 +71,8 @@ func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.En return err }, recvWithContext: func(ctx context.Context) (*MethodResult, error) { - info := &LoggingInfo{ - service: "StreamingInterceptors", - method: "Method", - callType: goa.InterceptorStreamingRecv, + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: &loggingMethodInfo{}, } res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.RecvWithContext(ctx) diff --git a/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden index da2e8aa555..b36825136d 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden @@ -3,18 +3,29 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + // Service returns the service selected for this call. + Service() string + // Method returns the method selected for this call. + Method() string + // CallType returns whether this is an endpoint call, stream send, or stream receive. + CallType() goa.InterceptorCallType + // RawPayload returns the value passed to the interceptor. + RawPayload() any + // ClientStreamingPayload returns selected fields from the outgoing stream payload. + ClientStreamingPayload() LoggingStreamingPayload + // ServerStreamingPayload returns selected fields from the incoming stream payload. + ServerStreamingPayload(any) LoggingStreamingPayload + // ClientStreamingResult returns selected fields from the incoming stream result. + ClientStreamingResult(any) LoggingStreamingResult + // ServerStreamingResult returns selected fields from the outgoing stream result. + ServerStreamingResult() LoggingStreamingResult } // LoggingStreamingPayload provides type-safe access to the method streaming payload. @@ -34,8 +45,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodStreamingSendInfo struct { + *loggingMethodInfo + } + loggingMethodStreamingRecvInfo struct { + *loggingMethodInfo + } loggingMethodStreamingPayload struct { payload *MethodStreamingPayload } @@ -48,54 +68,59 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptors" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a stream send. +func (info *loggingMethodStreamingSendInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend +} + +// CallType reports that this is a stream receive. +func (info *loggingMethodStreamingRecvInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv } -// ClientStreamingPayload returns a type-safe accessor for the method streaming payload for a client-side interceptor. -func (info *LoggingInfo) ClientStreamingPayload() LoggingStreamingPayload { - return &loggingMethodStreamingPayload{payload: info.RawPayload().(*MethodStreamingPayload)} +// ClientStreamingPayload returns this method's outgoing streaming payload fields. +func (info *loggingMethodInfo) ClientStreamingPayload() LoggingStreamingPayload { + return &loggingMethodStreamingPayload{payload: info.rawPayload.(*MethodStreamingPayload)} } -// ClientStreamingResult returns a type-safe accessor for the method streaming result for a client-side interceptor. -func (info *LoggingInfo) ClientStreamingResult(res any) LoggingStreamingResult { - return &loggingMethodStreamingResult{result: res.(*MethodResult)} +// ServerStreamingPayload returns this method's incoming streaming payload fields. +func (info *loggingMethodInfo) ServerStreamingPayload(payload any) LoggingStreamingPayload { + return &loggingMethodStreamingPayload{payload: payload.(*MethodStreamingPayload)} } -// ServerStreamingPayload returns a type-safe accessor for the method streaming payload for a server-side interceptor. -func (info *LoggingInfo) ServerStreamingPayload(pay any) LoggingStreamingPayload { - return &loggingMethodStreamingPayload{payload: pay.(*MethodStreamingPayload)} +// ClientStreamingResult returns this method's incoming streaming result fields. +func (info *loggingMethodInfo) ClientStreamingResult(result any) LoggingStreamingResult { + return &loggingMethodStreamingResult{result: result.(*MethodResult)} } -// ServerStreamingResult returns a type-safe accessor for the method streaming result for a server-side interceptor. -func (info *LoggingInfo) ServerStreamingResult() LoggingStreamingResult { - return &loggingMethodStreamingResult{result: info.RawPayload().(*MethodResult)} +// ServerStreamingResult returns this method's outgoing streaming result fields. +func (info *loggingMethodInfo) ServerStreamingResult() LoggingStreamingResult { + return &loggingMethodStreamingResult{result: info.rawPayload.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *loggingMethodStreamingPayload) Chunk() string { if p.payload.Chunk == nil { diff --git a/codegen/service/testdata/interceptors_dsls.go b/codegen/service/testdata/interceptors_dsls.go index d901825e2c..7ab1a01e9a 100644 --- a/codegen/service/testdata/interceptors_dsls.go +++ b/codegen/service/testdata/interceptors_dsls.go @@ -73,6 +73,25 @@ var SingleClientInterceptorDSL = func() { }) } +// LeadingInitialismInterceptorDSL defines an interceptor whose name starts with +// the common JWT initialism. +var LeadingInitialismInterceptorDSL = func() { + Interceptor("JWTAuth") + Service("LeadingInitialismInterceptor", func() { + ServerInterceptor("JWTAuth") + ClientInterceptor("JWTAuth") + Method("GetInfo", func() { + Payload(func() { + Attribute("id", Int) + }) + Result(func() { + Attribute("value", String) + }) + HTTP(func() { GET("/") }) + }) + }) +} + var MultipleInterceptorsDSL = func() { Interceptor("logging") Interceptor("tracing") @@ -112,6 +131,98 @@ var InterceptorWithReadPayloadDSL = func() { }) } +var InterceptorWithExternalReadPayloadDSL = func() { + var UUID = Type("UUID", String, func() { + Meta("struct:pkg:path", "types") + }) + Interceptor("authorization", func() { + ReadPayload(func() { + Attribute("org_id") + }) + }) + Service("InterceptorWithExternalReadPayload", func() { + ServerInterceptor("authorization") + Method("Method", func() { + Payload(func() { + Attribute("org_id", UUID) + Required("org_id") + }) + HTTP(func() { POST("/") }) + }) + }) +} + +var InterceptorWithExternalPayloadDSL = func() { + var Event = Type("Event", func() { + Attribute("runtime_session_id", String) + Required("runtime_session_id") + Meta("struct:pkg:path", "types") + }) + Interceptor("identify", func() { + ReadPayload(func() { + Attribute("runtime_session_id") + }) + }) + Service("InterceptorWithExternalPayload", func() { + ServerInterceptor("identify") + Method("Append", func() { + Payload(Event) + HTTP(func() { POST("/") }) + }) + }) +} + +var MixedInterceptorsWithExternalClientPayloadDSL = func() { + var UUID = Type("UUID", String, func() { + Meta("struct:pkg:path", "types") + }) + Interceptor("logging") + Interceptor("authorization", func() { + ReadPayload(func() { + Attribute("org_id") + }) + }) + Service("MixedInterceptorsWithExternalClientPayload", func() { + ServerInterceptor("logging") + ClientInterceptor("authorization") + Method("Method", func() { + Payload(func() { + Attribute("org_id", UUID) + Required("org_id") + }) + HTTP(func() { POST("/") }) + }) + }) +} + +var MergedInterceptorsWithExternalClientPayloadDSL = func() { + var Event = Type("Event", func() { + Attribute("runtime_session_id", String) + Required("runtime_session_id") + Meta("struct:pkg:path", "types") + }) + Interceptor("identify", func() { + ReadPayload(func() { + Attribute("runtime_session_id") + }) + }) + Service("MergedInterceptorsWithExternalClientPayload", func() { + Method("ServerMethod", func() { + ServerInterceptor("identify") + Payload(func() { + Attribute("runtime_session_id", String) + Required("runtime_session_id") + }) + HTTP(func() { POST("/server") }) + }) + Method("ClientMethod", func() { + ClientInterceptor("identify") + Payload(Event) + HTTP(func() { POST("/client") }) + }) + }) +} + var InterceptorWithWritePayloadDSL = func() { Interceptor("validation", func() { WritePayload(func() { @@ -285,6 +396,32 @@ var StreamingInterceptorsWithReadStreamingResultDSL = func() { }) } +var MixedResultStreamingInterceptorsDSL = func() { + Summary := Type("Summary", func() { + Field(1, "count", Int) + }) + Event := Type("Event", func() { + Field(1, "message", String) + }) + Interceptor("logging", func() { + ReadStreamingResult(func() { + Attribute("message") + }) + }) + Service("MixedResultStreamingInterceptors", func() { + ServerInterceptor("logging") + ClientInterceptor("logging") + Method("Method", func() { + Result(Summary) + StreamingResult(Event) + HTTP(func() { + GET("/stream") + ServerSentEvents() + }) + }) + }) +} + var StreamingInterceptorsWithReadPayloadDSL = func() { Interceptor("logging", func() { ReadPayload(func() { diff --git a/codegen/service/testdata/nested-alpha/alpha.go b/codegen/service/testdata/nested-alpha/alpha.go new file mode 100644 index 0000000000..3e74569aa4 --- /dev/null +++ b/codegen/service/testdata/nested-alpha/alpha.go @@ -0,0 +1,7 @@ +// Package nestedalpha supplies a named reflected child type for conversion tests. +package nestedalpha + +// Child is the alpha branch embedded by the external envelope fixture. +type Child struct { + Value string +} diff --git a/codegen/service/testdata/nested-beta/beta.go b/codegen/service/testdata/nested-beta/beta.go new file mode 100644 index 0000000000..d07364725c --- /dev/null +++ b/codegen/service/testdata/nested-beta/beta.go @@ -0,0 +1,7 @@ +// Package nestedbeta supplies a same-named reflected child type from a different package. +package nestedbeta + +// Child is the beta branch embedded by the external envelope fixture. +type Child struct { + Value string +} diff --git a/codegen/service/testdata/nested-outer/outer.go b/codegen/service/testdata/nested-outer/outer.go new file mode 100644 index 0000000000..d814276d74 --- /dev/null +++ b/codegen/service/testdata/nested-outer/outer.go @@ -0,0 +1,17 @@ +// Package nestedouter supplies an external conversion shape whose child types +// come from two distinct Go packages. +package nestedouter + +import ( + unusedalpha "goa.design/goa/v3/codegen/service/testdata/a-nested-alpha" + nestedalpha "goa.design/goa/v3/codegen/service/testdata/nested-alpha" + nestedbeta "goa.design/goa/v3/codegen/service/testdata/nested-beta" +) + +// Envelope contains mapped same-named child types and one deliberately +// unmapped child whose package name collides with the mapped alpha package. +type Envelope struct { + Alpha *nestedalpha.Child + Beta *nestedbeta.Child + Unused *unusedalpha.Child +} diff --git a/codegen/service/testdata/service_dsls.go b/codegen/service/testdata/service_dsls.go index cd0bc6ff91..995a1d34e5 100644 --- a/codegen/service/testdata/service_dsls.go +++ b/codegen/service/testdata/service_dsls.go @@ -82,6 +82,16 @@ var MultipleMethodsDSL = func() { }) } +var RepeatedInlineErrorsDSL = func() { + Service("Secured", func() { + for _, method := range []string{"Read", "Write", "Delete"} { + Method(method, func() { + Error("invalid_scopes", String) + }) + } + }) +} + var UnionMethodDSL = func() { var AUnion = Type("AUnion", func() { OneOf("Values", func() { @@ -159,6 +169,53 @@ var PkgPathUnionDSL = func() { }) } +// PkgPathUnionNameScopeDSL exercises services that independently declare the +// same structural union in relocated files that compile in one Go package. +var PkgPathUnionNameScopeDSL = func() { + var FirstValue = Type("FirstValue", func() { + Meta("struct:pkg:path", "types") + Meta("type:generate:force") + OneOf("Value", func() { + Attribute("Bool", Boolean) + Attribute("Enum", String) + Attribute("Number", Float64) + }) + }) + var SecondValue = Type("SecondValue", func() { + Meta("struct:pkg:path", "types") + Meta("type:generate:force") + OneOf("Value", func() { + Attribute("Bool", Boolean) + Attribute("Enum", String) + Attribute("Number", Float64) + }) + }) + var ThirdValue = Type("ThirdValue", func() { + Meta("struct:pkg:path", "types") + Meta("type:generate:force") + OneOf("Value", func() { + Attribute("Bool", Boolean) + Attribute("Enum", String) + Attribute("Number", Float64) + }) + }) + Service("FirstValueService", func() { + Method("Read", func() { + Payload(FirstValue) + }) + }) + Service("SecondValueService", func() { + Method("Read", func() { + Payload(SecondValue) + }) + }) + Service("ThirdValueService", func() { + Method("Read", func() { + Payload(ThirdValue) + }) + }) +} + // PkgPathUnionJSONFieldDSL tests OneOf branches declared with JSONField in a // struct:pkg:path type. var PkgPathUnionJSONFieldDSL = func() { @@ -388,7 +445,7 @@ var WithExplicitAndDefaultViewsDSL = func() { Method("A", func() { Result(RTWithViews) }) - Method("A", func() { + Method("B", func() { Result(RTWithViews, func() { View("tiny") }) @@ -1039,6 +1096,22 @@ var PkgPathDupeDSL = func() { }) } +var PkgPathSharedRolesDSL = func() { + var Shared = Type("Shared", func() { + Attribute("IntField", Int) + Meta("struct:pkg:path", "shared") + }) + + Service("PkgPathSharedRoles", func() { + Method("Exchange", func() { + Payload(Shared) + StreamingPayload(Shared) + Result(Shared) + StreamingResult(Shared) + }) + }) +} + var PkgPathPayloadAttributeDSL = func() { var Foo = Type("Foo", func() { Attribute("IntField", Int) diff --git a/codegen/service/testdata/views_code.go b/codegen/service/testdata/views_code.go index d4690ae3d2..857b6ac3b2 100644 --- a/codegen/service/testdata/views_code.go +++ b/codegen/service/testdata/views_code.go @@ -229,12 +229,6 @@ func ValidateResultTypeViewTiny(result *ResultTypeView) (err error) { } return } - -// ValidateUserTypeView runs the validations defined on UserTypeView. -func ValidateUserTypeView(result *UserTypeView) (err error) { - - return -} ` const ResultWithResultTypeCode = `// RT is the viewed result type that is projected based on a view. @@ -328,11 +322,17 @@ func ValidateRT(result *RT) (err error) { // view. func ValidateRTView(result *RTView) (err error) { + if result.B == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("b", "result")) + } if result.B != nil { if err2 := ValidateRT2ViewExtended(result.B); err2 != nil { err = goa.MergeErrors(err, err2) } } + if result.C == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("c", "result")) + } if result.C != nil { if err2 := ValidateRT3View(result.C); err2 != nil { err = goa.MergeErrors(err, err2) @@ -345,11 +345,17 @@ func ValidateRTView(result *RTView) (err error) { // view. func ValidateRTViewTiny(result *RTView) (err error) { + if result.B == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("b", "result")) + } if result.B != nil { if err2 := ValidateRT2ViewTiny(result.B); err2 != nil { err = goa.MergeErrors(err, err2) } } + if result.C == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("c", "result")) + } if result.C != nil { if err2 := ValidateRT3View(result.C); err2 != nil { err = goa.MergeErrors(err, err2) @@ -391,12 +397,6 @@ func ValidateRT2ViewTiny(result *RT2View) (err error) { return } -// ValidateUserTypeView runs the validations defined on UserTypeView. -func ValidateUserTypeView(result *UserTypeView) (err error) { - - return -} - // ValidateRT3View runs the validations defined on RT3View using the "default" // view. func ValidateRT3View(result *RT3View) (err error) { @@ -460,6 +460,7 @@ func ValidateRT(result *RT) (err error) { // ValidateRTView runs the validations defined on RTView using the "default" // view. func ValidateRTView(result *RTView) (err error) { + if result.A == nil { err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) } @@ -474,6 +475,7 @@ func ValidateRTView(result *RTView) (err error) { // ValidateRTViewTiny runs the validations defined on RTView using the "tiny" // view. func ValidateRTViewTiny(result *RTView) (err error) { + if result.A == nil { err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) } @@ -646,6 +648,9 @@ func ValidateAnotherResult(result *AnotherResult) (err error) { // "default" view. func ValidateSomeRTView(result *SomeRTView) (err error) { + if result.A == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) + } if result.A != nil { if err2 := ValidateSomeRTCollectionViewTiny(result.A); err2 != nil { err = goa.MergeErrors(err, err2) @@ -658,6 +663,9 @@ func ValidateSomeRTView(result *SomeRTView) (err error) { // "tiny" view. func ValidateSomeRTViewTiny(result *SomeRTView) (err error) { + if result.A == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) + } if result.A != nil { if err2 := ValidateSomeRTCollectionView(result.A); err2 != nil { err = goa.MergeErrors(err, err2) @@ -692,6 +700,9 @@ func ValidateSomeRTCollectionViewTiny(result SomeRTCollectionView) (err error) { // using the "default" view. func ValidateAnotherResultView(result *AnotherResultView) (err error) { + if result.A == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) + } if result.A != nil { if err2 := ValidateAnotherResultCollectionView(result.A); err2 != nil { err = goa.MergeErrors(err, err2) @@ -738,19 +749,11 @@ var ( func ValidateRT(result *RT) (err error) { switch result.View { case "default", "": - err = ValidateRTView(result.Projected) default: err = goa.InvalidEnumValueError("view", result.View, []any{"default"}) } return } - -// ValidateRTView runs the validations defined on RTView using the "default" -// view. -func ValidateRTView(result *RTView) (err error) { - - return -} ` const ResultWithEnumType = `// Result is the viewed result type that is projected based on a view. @@ -840,25 +843,11 @@ var ( func ValidateRT(result *RT) (err error) { switch result.View { case "default", "": - err = ValidateRTView(result.Projected) default: err = goa.InvalidEnumValueError("view", result.View, []any{"default"}) } return } - -// ValidateRTView runs the validations defined on RTView using the "default" -// view. -func ValidateRTView(result *RTView) (err error) { - - return -} - -// ValidateUserTypeView runs the validations defined on UserTypeView. -func ValidateUserTypeView(result *UserTypeView) (err error) { - - return -} ` -const ResultWithOneOfInResultTypeCode = "// OneOfResource is the viewed result type that is projected based on a view.\ntype OneOfResource struct {\n\t// Type to project\n\tProjected *OneOfResourceView\n\t// View to render\n\tView string\n}\n\n// OneOfResourceView is a type that runs validations on a projected type.\ntype OneOfResourceView struct {\n\t// Data (type depends on flag)\n\tData *OneOfValueView\n}\n\n// OneOfValueView is a type that runs validations on a projected type.\ntype OneOfValueView struct {\n\tFlag Flag\n}\n\n// FlagAstringView is a type that runs validations on a projected type.\ntype FlagAstringView string\n\n// FlagAintView is a type that runs validations on a projected type.\ntype FlagAintView int64\n\n// Flag is a sum-type union.\ntype Flag struct {\n\tkind FlagKind\n\tAstring FlagAstringView\n\tAint FlagAintView\n}\n\n// FlagKind enumerates the union variants for Flag.\ntype FlagKind string\n\nconst (\n\t// FlagKindAstring identifies the astring branch of the union.\n\tFlagKindAstring FlagKind = \"astring\"\n\t// FlagKindAint identifies the aint branch of the union.\n\tFlagKindAint FlagKind = \"aint\"\n)\n\n// Kind returns the discriminator value of the union.\nfunc (u Flag) Kind() FlagKind {\n\treturn u.kind\n}\n\n// NewFlagAstring constructs Flag with the astring branch set.\nfunc NewFlagAstring(v FlagAstringView) Flag {\n\treturn Flag{\n\t\tkind: FlagKindAstring,\n\t\tAstring: v,\n\t}\n}\n\n// AsAstring returns the value of the astring branch if set.\nfunc (u Flag) AsAstring() (_ FlagAstringView, ok bool) {\n\tif u.kind != FlagKindAstring {\n\t\treturn\n\t}\n\treturn u.Astring, true\n}\n\n// SetAstring sets the astring branch of the union.\nfunc (u *Flag) SetAstring(v FlagAstringView) {\n\tu.kind = FlagKindAstring\n\tu.Astring = v\n}\n\n// NewFlagAint constructs Flag with the aint branch set.\nfunc NewFlagAint(v FlagAintView) Flag {\n\treturn Flag{\n\t\tkind: FlagKindAint,\n\t\tAint: v,\n\t}\n}\n\n// AsAint returns the value of the aint branch if set.\nfunc (u Flag) AsAint() (_ FlagAintView, ok bool) {\n\tif u.kind != FlagKindAint {\n\t\treturn\n\t}\n\treturn u.Aint, true\n}\n\n// SetAint sets the aint branch of the union.\nfunc (u *Flag) SetAint(v FlagAintView) {\n\tu.kind = FlagKindAint\n\tu.Aint = v\n}\n\n// Validate ensures the union discriminant is valid.\nfunc (u Flag) Validate() error {\n\tswitch u.kind {\n\tcase \"\":\n\t\treturn goa.InvalidEnumValueError(\"type\", \"\", []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\tcase FlagKindAstring:\n\t\treturn nil\n\tcase FlagKindAint:\n\t\treturn nil\n\tdefault:\n\t\treturn goa.InvalidEnumValueError(\"type\", u.kind, []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\t}\n}\n\n// MarshalJSON marshals the union into the canonical {type,value} JSON shape.\nfunc (u Flag) MarshalJSON() ([]byte, error) {\n\tif err := u.Validate(); err != nil {\n\t\treturn nil, err\n\t}\n\tvar (\n\t\tvalue any\n\t)\n\tswitch u.kind {\n\tcase FlagKindAstring:\n\t\tvalue = u.Astring\n\tcase FlagKindAint:\n\t\tvalue = u.Aint\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unexpected Flag discriminant %q\", u.kind)\n\t}\n\treturn json.Marshal(struct {\n\t\tType string `json:\"type\"`\n\t\tValue any `json:\"value\"`\n\t}{\n\t\tType: string(u.kind),\n\t\tValue: value,\n\t})\n}\n\n// UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape.\nfunc (u *Flag) UnmarshalJSON(data []byte) error {\n\tvar raw struct {\n\t\tType string `json:\"type\"`\n\t\tValue json.RawMessage `json:\"value\"`\n\t}\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n\tif len(raw.Value) == 0 {\n\t\treturn goa.MissingFieldError(\"value\", \"Flag\")\n\t}\n\tif bytes.Equal(bytes.TrimSpace(raw.Value), []byte(\"null\")) {\n\t\treturn goa.InvalidFieldTypeError(\"value\", nil, \"non-null JSON value\")\n\t}\n\tswitch raw.Type {\n\tcase string(FlagKindAstring):\n\t\tvar v FlagAstringView\n\t\tif err := json.Unmarshal(raw.Value, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.kind = FlagKindAstring\n\t\tu.Astring = v\n\tcase string(FlagKindAint):\n\t\tvar v FlagAintView\n\t\tif err := json.Unmarshal(raw.Value, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.kind = FlagKindAint\n\t\tu.Aint = v\n\tdefault:\n\t\tif raw.Type == \"\" {\n\t\t\treturn goa.MissingFieldError(\"type\", \"Flag\")\n\t\t}\n\t\treturn goa.InvalidEnumValueError(\"type\", raw.Type, []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\t}\n\treturn nil\n}\n\nvar (\n\t// OneOfResourceMap is a map indexing the attribute names of OneOfResource by\n\t// view name.\n\tOneOfResourceMap = map[string][]string{\n\t\t\"default\": {\n\t\t\t\"data\",\n\t\t},\n\t}\n)\n\n// ValidateOneOfResource runs the validations defined on the viewed result type\n// OneOfResource.\nfunc ValidateOneOfResource(result *OneOfResource) (err error) {\n\tswitch result.View {\n\tcase \"default\", \"\":\n\t\terr = ValidateOneOfResourceView(result.Projected)\n\tdefault:\n\t\terr = goa.InvalidEnumValueError(\"view\", result.View, []any{\"default\"})\n\t}\n\treturn\n}\n\n// ValidateOneOfResourceView runs the validations defined on OneOfResourceView\n// using the \"default\" view.\nfunc ValidateOneOfResourceView(result *OneOfResourceView) (err error) {\n\tif result.Data == nil {\n\t\terr = goa.MergeErrors(err, goa.MissingFieldError(\"data\", \"result\"))\n\t}\n\treturn\n}\n\n// ValidateOneOfValueView runs the validations defined on OneOfValueView.\nfunc ValidateOneOfValueView(result *OneOfValueView) (err error) {\n\n\treturn\n}\n\n// ValidateFlagAstringView runs the validations defined on FlagAstringView.\nfunc ValidateFlagAstringView(result FlagAstringView) (err error) {\n\n\treturn\n}\n\n// ValidateFlagAintView runs the validations defined on FlagAintView.\nfunc ValidateFlagAintView(result FlagAintView) (err error) {\n\n\treturn\n}\n" +const ResultWithOneOfInResultTypeCode = "// OneOfResource is the viewed result type that is projected based on a view.\ntype OneOfResource struct {\n\t// Type to project\n\tProjected *OneOfResourceView\n\t// View to render\n\tView string\n}\n\n// OneOfResourceView is a type that runs validations on a projected type.\ntype OneOfResourceView struct {\n\t// Data (type depends on flag)\n\tData *OneOfValueView\n}\n\n// OneOfValueView is a type that runs validations on a projected type.\ntype OneOfValueView struct {\n\tFlag Flag\n}\n\n// FlagAstringView is a type that runs validations on a projected type.\ntype FlagAstringView string\n\n// FlagAintView is a type that runs validations on a projected type.\ntype FlagAintView int64\n\n// Flag holds exactly one of its branch values.\ntype Flag struct {\n\tkind FlagKind\n\tAstring FlagAstringView\n\tAint FlagAintView\n}\n\n// FlagKind records which Flag branch is selected.\ntype FlagKind string\n\nconst (\n\t// FlagKindAstring identifies the astring branch.\n\tFlagKindAstring FlagKind = \"astring\"\n\t// FlagKindAint identifies the aint branch.\n\tFlagKindAint FlagKind = \"aint\"\n)\n\n// Kind returns the selected branch.\nfunc (u Flag) Kind() FlagKind {\n\treturn u.kind\n}\n\n// NewFlagAstring constructs Flag with the astring branch set.\nfunc NewFlagAstring(v FlagAstringView) Flag {\n\treturn Flag{\n\t\tkind: FlagKindAstring,\n\t\tAstring: v,\n\t}\n}\n\n// AsAstring returns the value when the astring branch is selected.\nfunc (u Flag) AsAstring() (_ FlagAstringView, ok bool) {\n\tif u.kind != FlagKindAstring {\n\t\treturn\n\t}\n\treturn u.Astring, true\n}\n\n// SetAstring selects the astring branch and stores v.\nfunc (u *Flag) SetAstring(v FlagAstringView) {\n\tu.kind = FlagKindAstring\n\tu.Astring = v\n}\n\n// NewFlagAint constructs Flag with the aint branch set.\nfunc NewFlagAint(v FlagAintView) Flag {\n\treturn Flag{\n\t\tkind: FlagKindAint,\n\t\tAint: v,\n\t}\n}\n\n// AsAint returns the value when the aint branch is selected.\nfunc (u Flag) AsAint() (_ FlagAintView, ok bool) {\n\tif u.kind != FlagKindAint {\n\t\treturn\n\t}\n\treturn u.Aint, true\n}\n\n// SetAint selects the aint branch and stores v.\nfunc (u *Flag) SetAint(v FlagAintView) {\n\tu.kind = FlagKindAint\n\tu.Aint = v\n}\n\n// Validate ensures exactly one valid branch is selected.\nfunc (u Flag) Validate() error {\n\tswitch u.kind {\n\tcase \"\":\n\t\treturn goa.InvalidEnumValueError(\"type\", \"\", []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\tcase FlagKindAstring:\n\t\treturn nil\n\tcase FlagKindAint:\n\t\treturn nil\n\tdefault:\n\t\treturn goa.InvalidEnumValueError(\"type\", u.kind, []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\t}\n}\n\n// MarshalJSON marshals the union into the canonical {type,value} JSON shape.\nfunc (u Flag) MarshalJSON() ([]byte, error) {\n\tif err := u.Validate(); err != nil {\n\t\treturn nil, err\n\t}\n\tvar (\n\t\tvalue any\n\t)\n\tswitch u.kind {\n\tcase FlagKindAstring:\n\t\tvalue = u.Astring\n\tcase FlagKindAint:\n\t\tvalue = u.Aint\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unexpected Flag kind %q\", u.kind)\n\t}\n\treturn json.Marshal(struct {\n\t\tType string `json:\"type\"`\n\t\tValue any `json:\"value\"`\n\t}{\n\t\tType: string(u.kind),\n\t\tValue: value,\n\t})\n}\n\n// UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape.\nfunc (u *Flag) UnmarshalJSON(data []byte) error {\n\tvar raw struct {\n\t\tType string `json:\"type\"`\n\t\tValue json.RawMessage `json:\"value\"`\n\t}\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n\tif len(raw.Value) == 0 {\n\t\treturn goa.MissingFieldError(\"value\", \"Flag\")\n\t}\n\tif bytes.Equal(bytes.TrimSpace(raw.Value), []byte(\"null\")) {\n\t\treturn goa.InvalidFieldTypeError(\"value\", nil, \"non-null JSON value\")\n\t}\n\tswitch raw.Type {\n\tcase string(FlagKindAstring):\n\t\tvar v FlagAstringView\n\t\tif err := json.Unmarshal(raw.Value, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.kind = FlagKindAstring\n\t\tu.Astring = v\n\tcase string(FlagKindAint):\n\t\tvar v FlagAintView\n\t\tif err := json.Unmarshal(raw.Value, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.kind = FlagKindAint\n\t\tu.Aint = v\n\tdefault:\n\t\tif raw.Type == \"\" {\n\t\t\treturn goa.MissingFieldError(\"type\", \"Flag\")\n\t\t}\n\t\treturn goa.InvalidEnumValueError(\"type\", raw.Type, []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\t}\n\treturn nil\n}\n\nvar (\n\t// OneOfResourceMap is a map indexing the attribute names of OneOfResource by\n\t// view name.\n\tOneOfResourceMap = map[string][]string{\n\t\t\"default\": {\n\t\t\t\"data\",\n\t\t},\n\t}\n)\n\n// ValidateOneOfResource runs the validations defined on the viewed result type\n// OneOfResource.\nfunc ValidateOneOfResource(result *OneOfResource) (err error) {\n\tswitch result.View {\n\tcase \"default\", \"\":\n\t\terr = ValidateOneOfResourceView(result.Projected)\n\tdefault:\n\t\terr = goa.InvalidEnumValueError(\"view\", result.View, []any{\"default\"})\n\t}\n\treturn\n}\n\n// ValidateOneOfResourceView runs the validations defined on OneOfResourceView\n// using the \"default\" view.\nfunc ValidateOneOfResourceView(result *OneOfResourceView) (err error) {\n\tif result.Data == nil {\n\t\terr = goa.MergeErrors(err, goa.MissingFieldError(\"data\", \"result\"))\n\t}\n\treturn\n}\n" diff --git a/codegen/service/testing.go b/codegen/service/testing.go index 4b8749f269..76b8f13393 100644 --- a/codegen/service/testing.go +++ b/codegen/service/testing.go @@ -1,3 +1,5 @@ +// This file evaluates isolated service DSL fixtures. Generation construction, +// rather than the fixture, performs the final raw-method normalization step. package service import ( @@ -5,7 +7,6 @@ import ( "github.com/stretchr/testify/require" - "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) @@ -24,14 +25,12 @@ func initDSL(t *testing.T) *expr.RootExpr { return root } -// runDSL returns the DSL root resulting from running the given DSL. The root -// is normalized like the production Generate flow does before the generators -// read the design. +// runDSL evaluates the given DSL and returns its root. Test generation helpers +// normalize the root when they construct the generation. func runDSL(t *testing.T, dsl func()) *expr.RootExpr { root := initDSL(t) require.True(t, eval.Execute(dsl, nil)) require.NoError(t, eval.RunDSL()) - codegen.NormalizeRoot(root) return root } diff --git a/codegen/service/transform_helper_operation_contract_test.go b/codegen/service/transform_helper_operation_contract_test.go new file mode 100644 index 0000000000..0256519024 --- /dev/null +++ b/codegen/service/transform_helper_operation_contract_test.go @@ -0,0 +1,265 @@ +// This file verifies recursive transform helpers retain the field-presence +// operation selected by each result view from planning through rendered calls. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +type retainedTransformOperation struct { + conversion *viewConversionFacts + init *InitData + helper codegen.TransformHelper + definition *codegen.TransformFunctionData +} + +// TestRecursiveTransformHelpersRetainRequiredness catches required and +// optional recursive field operations that are collapsed because their named +// source and target types have the same origins. +func TestRecursiveTransformHelpersRetainRequiredness(t *testing.T) { + forwardPlan := recursiveTransformPlan(t, false) + operations := retainedRecursiveTransformOperations(t, forwardPlan) + required := operations[expr.DefaultView] + optional := operations["optional"] + require.NotNil(t, required) + require.NotNil(t, optional) + + require.NotSame(t, required.definition.Declaration, optional.definition.Declaration) + require.NotEqual(t, required.definition.Code, optional.definition.Code) + require.NotContains(t, required.definition.Code, "if v == nil") + require.Contains(t, optional.definition.Code, "if v == nil") + require.Contains(t, required.init.Code, required.definition.Declaration.Name()+"(") + require.Contains(t, optional.init.Code, optional.definition.Declaration.Name()+"(") + require.Same( + t, + required.helper.Declaration, + required.definition.Declaration, + ) + require.Same( + t, + optional.helper.Declaration, + optional.definition.Declaration, + ) + + reversePlan := recursiveTransformPlan(t, true) + reverseOperations := retainedRecursiveTransformOperations(t, reversePlan) + for _, view := range []string{expr.DefaultView, "optional"} { + require.Equal(t, operations[view].definition.Declaration.Name(), reverseOperations[view].definition.Declaration.Name()) + require.Equal(t, operations[view].definition.Code, reverseOperations[view].definition.Code) + } + + forwardFiles, err := Files(forwardPlan) + require.NoError(t, err) + reverseFiles, err := Files(reversePlan) + require.NoError(t, err) + + compileFiles := append([]*codegen.File(nil), forwardFiles...) + compileFiles = append(compileFiles, ExampleServiceFiles(forwardPlan)...) + compileGeneratedServiceFiles(t, compileFiles) + reverseCompileFiles := append([]*codegen.File(nil), reverseFiles...) + reverseCompileFiles = append(reverseCompileFiles, ExampleServiceFiles(reversePlan)...) + compileGeneratedServiceFiles(t, reverseCompileFiles) +} + +// TestRecursiveTransformHelpersRetainSiblingOccurrences catches package-name +// planning that collapses two optional fields because their recursive types +// share an authored origin and requiredness. +func TestRecursiveTransformHelpersRetainSiblingOccurrences(t *testing.T) { + root := codegen.RunDSL(t, func() { + node := dsl.Type("Node", func() { + dsl.Attribute("label", dsl.String) + dsl.Attribute("next", "Node") + dsl.Required("label") + }) + tree := dsl.ResultType("application/vnd.sibling-tree", func() { + dsl.TypeName("SiblingTree") + dsl.Attribute("left", node) + dsl.Attribute("right", node) + dsl.View(expr.DefaultView, func() { + dsl.Attribute("left") + dsl.Attribute("right") + }) + }) + dsl.Service("Trees", func() { + dsl.Method("Read", func() { + dsl.Result(tree) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + facts := plan.facts.serviceByID["Trees"] + require.NotNil(t, facts) + projected := facts.projections[facts.methods[0]].types[0] + var conversion *viewConversionFacts + for _, candidate := range projected.conversions { + if !candidate.toResult && candidate.viewName == expr.DefaultView { + conversion = candidate + break + } + } + require.NotNil(t, conversion) + helpers := conversion.plan.Helpers() + require.Len(t, helpers, 2) + require.False(t, helpers[0].Required) + require.False(t, helpers[1].Required) + require.NotSame(t, helpers[0].Declaration, helpers[1].Declaration) + + serviceData := plan.Services().Get("Trees") + require.NotNil(t, serviceData) + var init *InitData + for _, projectedData := range serviceData.projectedTypes { + for _, candidate := range projectedData.Projections { + if candidate.Declaration == conversion.constructor { + init = candidate + break + } + } + } + require.NotNil(t, init) + require.Len(t, init.Helpers, 2) + for _, helper := range helpers { + require.Contains(t, init.Code, helper.Declaration.Name()+"(") + var definition *codegen.TransformFunctionData + for _, candidate := range init.Helpers { + if candidate.ID == helper.ID { + definition = candidate + break + } + } + require.NotNil(t, definition) + require.Same(t, helper.Declaration, definition.Declaration) + } + + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, files) +} + +// recursiveTransformPlan builds equivalent result designs in either field and +// view order, then completes their retained service planning lifecycle. +func recursiveTransformPlan(t *testing.T, reverse bool) *Plan { + t.Helper() + root := codegen.RunDSL(t, func() { + node := dsl.Type("Node", func() { + dsl.Attribute("label", dsl.String) + dsl.Attribute("next", "Node") + dsl.Required("label") + }) + tree := dsl.ResultType("application/vnd.tree", func() { + dsl.TypeName("Tree") + requiredField := func() { + dsl.Attribute("required_node", node) + } + optionalField := func() { + dsl.Attribute("optional_node", node) + } + if reverse { + optionalField() + requiredField() + } else { + requiredField() + optionalField() + } + dsl.Required("required_node") + + requiredView := func() { + dsl.View(expr.DefaultView, func() { + dsl.Attribute("required_node") + }) + } + optionalView := func() { + dsl.View("optional", func() { + dsl.Attribute("optional_node") + }) + } + if reverse { + optionalView() + requiredView() + } else { + requiredView() + optionalView() + } + }) + dsl.Service("Trees", func() { + dsl.Method("Read", func() { + dsl.Result(tree) + }) + }) + }) + return retainedServicePlanForPackage(t, root) +} + +// retainedRecursiveTransformOperations returns the service-to-view helper +// operation, its call binding, and its rendered definition for each view. +func retainedRecursiveTransformOperations(t *testing.T, plan *Plan) map[string]*retainedTransformOperation { + t.Helper() + facts := plan.facts.serviceByID["Trees"] + require.NotNil(t, facts) + data := plan.Services().Get("Trees") + require.NotNil(t, data) + + var projectedFacts *projectedTypeFacts + for _, candidate := range facts.projections[facts.methods[0]].types { + if candidate.pair.source.Name() == "Tree" { + projectedFacts = candidate + break + } + } + require.NotNil(t, projectedFacts) + var projectedData *ProjectedTypeData + for _, candidate := range data.projectedTypes { + if candidate.Type.Origin() == projectedFacts.pair.projected.Origin() { + projectedData = candidate + break + } + } + require.NotNil(t, projectedData) + + operations := make(map[string]*retainedTransformOperation) + for _, conversion := range projectedFacts.conversions { + if conversion.toResult { + continue + } + helpers := conversion.plan.Helpers() + require.NotEmpty(t, helpers, conversion.viewName) + var selected codegen.TransformHelper + required := conversion.viewName == expr.DefaultView + for _, helper := range helpers { + if helper.Required == required { + selected = helper + break + } + } + require.NotNil(t, selected.Declaration, conversion.viewName) + var init *InitData + for _, candidate := range projectedData.Projections { + if candidate.Declaration == conversion.constructor { + init = candidate + break + } + } + require.NotNil(t, init, conversion.viewName) + var definition *codegen.TransformFunctionData + for _, helper := range init.Helpers { + if helper.ID == selected.ID { + definition = helper + break + } + } + require.NotNil(t, definition, conversion.viewName) + operations[conversion.viewName] = &retainedTransformOperation{ + conversion: conversion, + init: init, + helper: selected, + definition: definition, + } + } + return operations +} diff --git a/codegen/service/type_map_identity_contract_test.go b/codegen/service/type_map_identity_contract_test.go new file mode 100644 index 0000000000..2319387bda --- /dev/null +++ b/codegen/service/type_map_identity_contract_test.go @@ -0,0 +1,33 @@ +// This file verifies external type mappings follow the exact retained user +// type origin rather than a display name shared by unrelated declarations. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestTypeMapMatchesExactRetainedUserType catches a mapping selected only +// because an unrelated service type has the same display name. +func TestTypeMapMatchesExactRetainedUserType(t *testing.T) { + serviceType := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Shared", + UID: "service-shared", + } + mappedType := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Shared", + UID: "mapped-shared", + } + facts := &serviceFacts{ + reachableTypes: map[expr.UserType]struct{}{serviceType.Origin(): {}}, + } + + require.NotSame(t, serviceType.Origin(), mappedType.Origin()) + require.True(t, typeMapMatchesFacts(&expr.TypeMap{User: serviceType}, facts)) + require.False(t, typeMapMatchesFacts(&expr.TypeMap{User: mappedType}, facts)) +} diff --git a/codegen/service/type_plan.go b/codegen/service/type_plan.go new file mode 100644 index 0000000000..38d01cec11 --- /dev/null +++ b/codegen/service/type_plan.go @@ -0,0 +1,526 @@ +// This file records the fields, pointers, tags, declarations, and unions +// needed to write generated service types. +package service + +import ( + "fmt" + "path" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// planServiceTypeLayouts records every field, pointer, struct tag, package, and +// declaration needed to write service types after Generation.Freeze chooses +// every declaration and import name. +func planServiceTypeLayouts(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + binder := serviceGoTypeBinder(rootTypes, generation) + plan := func(attribute *expr.AttributeExpr, owner string) (*codegen.GoTypePlan, error) { + if attribute == nil { + return nil, nil + } + return codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ + Owner: owner, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: binder, + }) + } + userTypes := append(append([]*userTypeFacts(nil), facts.userTypes...), facts.errorTypes...) + for _, userType := range userTypes { + layout, err := plan(userType.userType.Attribute(), userType.declaration.PackagePath()) + if err != nil { + return err + } + userType.layout = layout + } + for _, errorFacts := range facts.errorFacts { + layout, err := plan(errorFacts.attribute, facts.packagePath) + if err != nil { + return err + } + errorFacts.layout = layout + } + for _, method := range facts.orderedMethods { + for _, attribute := range []*methodAttributeFacts{ + method.payload, + method.streamingPayload, + method.result, + method.streamingResult, + } { + if attribute == nil { + continue + } + layout, err := plan(attribute.attribute, facts.packagePath) + if err != nil { + return err + } + attribute.layout = layout + if userType, ok := attribute.attribute.Type.(expr.UserType); ok { + _, attribute.normalized = generation.NormalizedMethodType(userType) + } + if layout.TypeDeclaration() != nil { + userType := attribute.attribute.Type.(expr.UserType) + definition, err := plan(userType.Attribute(), layout.Owner()) + if err != nil { + return err + } + attribute.definition = definition + } + } + for _, errorFacts := range method.errors { + layout, err := plan(errorFacts.attribute, facts.packagePath) + if err != nil { + return err + } + errorFacts.layout = layout + } + } + for _, interceptor := range append( + append([]*interceptorFacts(nil), facts.serverInterceptorFacts...), + facts.clientInterceptorFacts..., + ) { + if len(interceptor.methods) == 0 { + continue + } + method := interceptor.methods[0] + accesses := []struct { + selection *expr.AttributeExpr + parent *methodAttributeFacts + target *[]*interceptorAccessFacts + }{ + {interceptor.readPayload, method.payload, &interceptor.readPayloadFields}, + {interceptor.writePayload, method.payload, &interceptor.writePayloadFields}, + {interceptor.readResult, method.result, &interceptor.readResultFields}, + {interceptor.writeResult, method.result, &interceptor.writeResultFields}, + {interceptor.readStreamingPayload, method.streamingPayload, &interceptor.readStreamingPayloadFields}, + {interceptor.writeStreamingPayload, method.streamingPayload, &interceptor.writeStreamingPayloadFields}, + {interceptor.readStreamingResult, method.streamingResult, &interceptor.readStreamingResultFields}, + {interceptor.writeStreamingResult, method.streamingResult, &interceptor.writeStreamingResultFields}, + } + for _, access := range accesses { + planned, err := planInterceptorAccess(access.selection, access.parent, facts.packagePath, binder) + if err != nil { + return err + } + *access.target = planned + } + } + for _, union := range facts.unions { + if err := planUnionRenderFacts(union, binder, generation.Package(union.declaration.PackagePath())); err != nil { + return err + } + } + return nil +} + +// planUnionRenderFacts records every Goa OneOf branch, nil rule, and Go type +// before Generation.Freeze chooses declaration and import names. +func planUnionRenderFacts(facts *unionFacts, binder codegen.GoTypeBinder, generatedPackage *codegen.GeneratedPackage) error { + facts.identity = codegen.NewUnionTypeID(facts.union) + facts.typeKey = facts.union.GetTypeKey() + facts.valueKey = facts.union.GetValueKey() + facts.branches = make([]*unionBranchFacts, len(facts.union.Values)) + for index, branch := range facts.union.Values { + declaration, err := generatedPackage.UnionBranch(facts.union, branch.Name) + if err != nil { + return err + } + layout, err := codegen.PlanGoType(branch.Attribute, codegen.GoTypePlanOptions{ + Owner: generatedPackage.ImportPath(), + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: binder, + }) + if err != nil { + return err + } + primitiveAliasType, hasPrimitiveAlias := primitiveAliasGoType(branch.Attribute.Type) + _, isUserType := branch.Attribute.Type.(expr.UserType) + _, hasCustomImport := layout.Import() + facts.branches[index] = &unionBranchFacts{ + name: branch.Name, + fieldName: codegen.Goify(branch.Name, true), + declaration: declaration, + layout: layout, + nilable: codegen.IsNilable(branch.Attribute.Type), + emitPrimitiveAlias: hasPrimitiveAlias && !isUserType && !hasCustomImport, + primitiveAliasType: primitiveAliasType, + } + } + return nil +} + +// planInterceptorAccess records the field names, pointer choices, and Go types +// exposed to an interceptor while the design expressions are available. +func planInterceptorAccess(selection *expr.AttributeExpr, parent *methodAttributeFacts, owner string, binder codegen.GoTypeBinder) ([]*interceptorAccessFacts, error) { + if selection == nil { + return nil, nil + } + object := expr.AsObject(selection.Type) + if object == nil { + return nil, fmt.Errorf("plan interceptor access: selection must be an object") + } + if len(*object) == 0 { + return nil, nil + } + result := make([]*interceptorAccessFacts, len(*object)) + for index, field := range *object { + attribute := parent.attribute.Find(field.Name) + if attribute == nil { + return nil, fmt.Errorf("plan interceptor access: attribute %q is not present in its method value", field.Name) + } + layout, err := codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ + Owner: owner, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: binder, + }) + if err != nil { + return nil, err + } + result[index] = &interceptorAccessFacts{ + attribute: expr.DupAtt(attribute), + name: codegen.Goify(field.Name, true), + pointer: parent.attribute.IsPrimitivePointer(field.Name, true), + layout: layout, + } + } + return result, nil +} + +// serviceGoTypeBinder maps authored service types and compiler-created copies +// to the generated Go declarations selected during collection. +func serviceGoTypeBinder(rootTypes *rootTypeSet, generation *codegen.Generation) codegen.GoTypeBinder { + return func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + owner := request.InheritedOwner + if location := codegen.UserTypeLocation(request.Attribute.Type); location != nil { + owner = path.Join(generation.GenPkg(), location.RelImportPath) + } + generatedPackage := generation.Package(owner) + switch request.Kind { + case codegen.GoNamed: + userType := request.Attribute.Type.(expr.UserType) + declaration, err := generatedPackage.Type(rootTypes.canonical(userType)) + if err != nil { + return codegen.GoTypeBinding{}, err + } + return codegen.GoTypeBinding{Owner: owner, Type: declaration}, nil + case codegen.GoUnion: + union := request.Attribute.Type.(*expr.Union) + declaration, err := generatedPackage.Union(union) + if err != nil { + return codegen.GoTypeBinding{}, err + } + return codegen.GoTypeBinding{Owner: owner, Union: declaration}, nil + default: + return codegen.GoTypeBinding{}, fmt.Errorf("bind unsupported retained Go type kind %s", request.Kind) + } + } +} + +// collectServiceUnionFacts selects each service Goa OneOf type once in every +// package that writes it and records its generated declaration. +func collectServiceUnionFacts(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + seenTypes := make(map[plannedUserType]struct{}) + seenUnions := make(map[unionDataKey]struct{}) + collect := func(attribute *expr.AttributeExpr, location *codegen.Location) error { + return collectUnionFacts(attribute, facts.packagePath, location, rootTypes, generation, seenTypes, seenUnions, &facts.unions) + } + for _, userType := range facts.userTypes { + if err := collect(&expr.AttributeExpr{Type: userType.userType}, userType.location); err != nil { + return err + } + } + for _, errorType := range facts.errorTypes { + if err := collect(&expr.AttributeExpr{Type: errorType.userType}, errorType.location); err != nil { + return err + } + } + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + var location *codegen.Location + if attribute != nil { + location = codegen.UserTypeLocation(attribute.Type) + } + if err := collect(attribute, location); err != nil { + return err + } + } + for _, methodError := range method.Errors { + if err := collect(methodError.AttributeExpr, codegen.UserTypeLocation(methodError.Type)); err != nil { + return err + } + } + } + return nil +} + +// collectUnionFacts recursively records union declarations while keeping +// unlocated nested types in the package inherited from their enclosing type. +func collectUnionFacts(attribute *expr.AttributeExpr, servicePath string, location *codegen.Location, rootTypes *rootTypeSet, generation *codegen.Generation, seenTypes map[plannedUserType]struct{}, seenUnions map[unionDataKey]struct{}, unions *[]*unionFacts) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + recurse := func(attribute *expr.AttributeExpr, location *codegen.Location) error { + return collectUnionFacts(attribute, servicePath, location, rootTypes, generation, seenTypes, seenUnions, unions) + } + switch actual := attribute.Type.(type) { + case expr.UserType: + typeLocation := codegen.UserTypeLocation(actual) + if typeLocation == nil { + typeLocation = location + } + owner := generation.Package(generatedPackagePath(generation.GenPkg(), servicePath, typeLocation)) + key := plannedUserType{userType: rootTypes.canonical(actual), owner: owner} + if _, exists := seenTypes[key]; exists { + return nil + } + seenTypes[key] = struct{}{} + return recurse(actual.Attribute(), typeLocation) + case *expr.Object: + for _, field := range sortedNamedAttributes(*actual) { + if err := recurse(field.Attribute, location); err != nil { + return err + } + } + case *expr.Array: + return recurse(actual.ElemType, location) + case *expr.Map: + if err := recurse(actual.KeyType, location); err != nil { + return err + } + return recurse(actual.ElemType, location) + case *expr.Union: + packagePath := generatedPackagePath(generation.GenPkg(), servicePath, location) + key := unionDataKey{packagePath: packagePath, identity: codegen.NewUnionTypeID(actual)} + if _, exists := seenUnions[key]; !exists { + declaration, err := generation.Package(packagePath).Union(actual) + if err != nil { + return err + } + seenUnions[key] = struct{}{} + *unions = append(*unions, &unionFacts{ + union: actual, + identity: codegen.NewUnionTypeID(actual), + typeKey: actual.GetTypeKey(), + valueKey: actual.GetValueKey(), + location: location, + declaration: declaration, + }) + } + for _, branch := range actual.Values { + if err := recurse(branch.Attribute, location); err != nil { + return err + } + } + } + return nil +} + +// typeMapMatchesFacts reports whether a user-supplied Go type mapping applies +// to a payload, result, error, stream value, or child type selected for this +// service. +func typeMapMatchesFacts(typeMap *expr.TypeMap, facts *serviceFacts) bool { + _, reachable := facts.reachableTypes[typeMap.User.Origin()] + return reachable +} + +// collectServiceTypeFacts selects the exact named types emitted for one +// service. Linking later formats these records without searching for the types +// again or deciding which generated package contains them. +func collectServiceTypeFacts(facts *serviceFacts, rootTypes []expr.UserType, canonical *rootTypeSet, generation *codegen.Generation) error { + seen := make(map[userTypeDataKey]struct{}) + for _, serviceError := range facts.errors { + selected, err := collectUserTypeFacts(serviceError.AttributeExpr, facts.packagePath, nil, canonical, generation, seen) + if err != nil { + return err + } + facts.errorTypes = append(facts.errorTypes, selected...) + } + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + if attribute == nil { + continue + } + location := (*codegen.Location)(nil) + inner := attribute + if userType, ok := attribute.Type.(expr.UserType); ok { + location = codegen.UserTypeLocation(userType) + if _, normalized := generation.NormalizedMethodType(userType); normalized || location == nil { + inner = userType.Attribute() + } + } + selected, err := collectUserTypeFacts(inner, facts.packagePath, location, canonical, generation, seen) + if err != nil { + return err + } + facts.userTypes = append(facts.userTypes, selected...) + } + for _, methodError := range method.Errors { + selected, err := collectUserTypeFacts(methodError.AttributeExpr, facts.packagePath, nil, canonical, generation, seen) + if err != nil { + return err + } + facts.errorTypes = append(facts.errorTypes, selected...) + } + } + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + if attribute == nil || attribute.Type == expr.Empty { + continue + } + if _, raw := attribute.Type.(*expr.Object); raw { + panic(fmt.Sprintf( + "service %q method %q declares a raw object type: codegen.NewGeneration must own the finalized design before generators read it", + facts.service.Name, method.Name)) + } + if userType, ok := attribute.Type.(expr.UserType); ok { + declaration, err := generation.Package(generatedPackagePath( + generation.GenPkg(), facts.packagePath, codegen.UserTypeLocation(userType), + )).Type(userType) + if err != nil { + return err + } + seen[userTypeDataKey{origin: userType.Origin(), declaration: declaration}] = struct{}{} + } + } + } + for _, userType := range rootTypes { + services, forced := userType.Attribute().Meta["type:generate:force"] + if !forced || len(services) > 0 && !containsString(services, facts.service.Name) { + continue + } + selected, err := collectUserTypeFacts( + &expr.AttributeExpr{Type: userType}, facts.packagePath, nil, canonical, generation, seen, + ) + if err != nil { + return err + } + facts.userTypes = append(facts.userTypes, selected...) + } + for _, userType := range facts.userTypes { + facts.reachableTypes[userType.userType.Origin()] = struct{}{} + } + return nil +} + +// collectUserTypeFacts recursively selects named types while carrying the +// package location inherited from an enclosing generated type. +func collectUserTypeFacts(attribute *expr.AttributeExpr, servicePath string, location *codegen.Location, canonical *rootTypeSet, generation *codegen.Generation, seen map[userTypeDataKey]struct{}) ([]*userTypeFacts, error) { + if attribute == nil || attribute.Type == expr.Empty { + return nil, nil + } + collect := func(attribute *expr.AttributeExpr, location *codegen.Location) ([]*userTypeFacts, error) { + return collectUserTypeFacts(attribute, servicePath, location, canonical, generation, seen) + } + var result []*userTypeFacts + switch actual := attribute.Type.(type) { + case expr.UserType: + typeLocation := codegen.UserTypeLocation(actual) + if typeLocation == nil { + typeLocation = location + } + declaration, err := generation.Package( + generatedPackagePath(generation.GenPkg(), servicePath, typeLocation), + ).Type(canonical.canonical(actual)) + if err != nil { + return nil, err + } + key := userTypeDataKey{origin: actual.Origin(), declaration: declaration} + if _, exists := seen[key]; exists { + return nil, nil + } + seen[key] = struct{}{} + result = append(result, &userTypeFacts{ + userType: actual, + name: actual.Name(), + description: actual.Attribute().Description, + errorName: retainedErrorName(actual), + serviceError: expr.IsErrorResult(actual), + location: typeLocation, + declaration: declaration, + }) + nested, err := collect(actual.Attribute(), typeLocation) + return append(result, nested...), err + case *expr.Object: + for _, field := range *actual { + selected, err := collect(field.Attribute, location) + if err != nil { + return nil, err + } + result = append(result, selected...) + } + case *expr.Array: + return collect(actual.ElemType, location) + case *expr.Map: + key, err := collect(actual.KeyType, location) + if err != nil { + return nil, err + } + value, err := collect(actual.ElemType, location) + return append(key, value...), err + case *expr.Union: + for _, branch := range actual.Values { + if userType, generated := generatedUnionBranch(branch, canonical); generated && location != nil { + selected, err := collect(&expr.AttributeExpr{Type: userType}, location) + if err != nil { + return nil, err + } + result = append(result, selected...) + continue + } + selected, err := collect(branch.Attribute, location) + if err != nil { + return nil, err + } + result = append(result, selected...) + } + } + return result, nil +} + +// This helper copies the Go expression returned by GoaErrorName while the +// design error metadata is still available. +func retainedErrorName(userType expr.UserType) string { + if object := expr.AsObject(userType); object != nil { + for _, field := range *object { + if _, ok := field.Attribute.Meta["struct:error:name"]; ok { + return fmt.Sprintf("e.%s", codegen.GoifyAtt(field.Attribute, field.Name, true)) + } + } + } + if value, ok := userType.Attribute().Meta["struct:error:name"]; ok { + return fmt.Sprintf("%q", value[0]) + } + return fmt.Sprintf("%q", userType.Name()) +} + +// containsString reports whether values contains target without introducing a +// second service-selection representation. +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/codegen/service/view_data.go b/codegen/service/view_data.go new file mode 100644 index 0000000000..1e04d1f06b --- /dev/null +++ b/codegen/service/view_data.go @@ -0,0 +1,590 @@ +// This file builds generated view types, constructors, and validation +// functions from the data selected during planning. +package service + +import ( + "bytes" + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// projectTypePairs rewrites a copied result graph into pointer-backed view +// types and returns each generated declaration with its exact source. The +// source Origin makes independently rebuilt plan and render graphs select the +// same package record. +func projectTypePairs(projected, source *expr.AttributeExpr, seen map[expr.UserType]expr.UserType) []*projectedTypePair { + collect := func(projected, source *expr.AttributeExpr) []*projectedTypePair { + return projectTypePairs(projected, source, seen) + } + switch projectedType := projected.Type.(type) { + case expr.UserType: + sourceType := source.Type.(expr.UserType) + origin := sourceType.Origin() + if existing, ok := seen[origin]; ok { + if existing != nil { + projected.Type = existing + } + return nil + } + seen[origin] = nil + projectedType.Rename(projectedType.Name() + "View") + nested := collect(projectedType.Attribute(), sourceType.Attribute()) + seen[origin] = projectedType + return append([]*projectedTypePair{{ + source: sourceType, + projected: projectedType, + sourceAttribute: source, + projectedAttribute: projected, + }}, nested...) + case *expr.Array: + return collect(projectedType.ElemType, source.Type.(*expr.Array).ElemType) + case *expr.Map: + sourceMap := source.Type.(*expr.Map) + pairs := collect(projectedType.KeyType, sourceMap.KeyType) + return append(pairs, collect(projectedType.ElemType, sourceMap.ElemType)...) + case *expr.Object: + sourceObject := source.Type.(*expr.Object) + var pairs []*projectedTypePair + for _, field := range *projectedType { + pairs = append(pairs, collect(field.Attribute, sourceObject.Attribute(field.Name))...) + } + return pairs + case *expr.Union: + sourceUnion := source.Type.(*expr.Union) + var pairs []*projectedTypePair + for index, branch := range projectedType.Values { + pairs = append(pairs, collect(branch.Attribute, sourceUnion.Values[index].Attribute)...) + } + return pairs + default: + return nil + } +} + +// projectedResultRoot returns the root attribute used to build result types +// containing only the fields in each view of m.Result. Generation records +// which method wrappers Goa created, so authored types with matching text stay +// unchanged. +func projectedResultRoot(generation *codegen.Generation, m *expr.MethodExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + if ut, ok := m.Result.Type.(*expr.UserTypeExpr); ok { + if _, normalized := generation.NormalizedMethodType(ut); !normalized { + return expr.DupAtt(m.Result), m.Result + } + return expr.DupAtt(ut.Attribute()), ut.Attribute() + } + return expr.DupAtt(m.Result), m.Result +} + +// hasResultType returns true if the given attribute has a result type recursively. +func hasResultType(att *expr.AttributeExpr, seens ...map[expr.UserType]struct{}) bool { + if _, ok := att.Type.(*expr.ResultTypeExpr); ok { + return true + } + var seen map[expr.UserType]struct{} + if len(seens) > 0 { + seen = seens[0] + } else { + seen = make(map[expr.UserType]struct{}) + } + switch a := att.Type.(type) { + case expr.UserType: + origin := a.Origin() + if _, ok := seen[origin]; ok { + return false + } + seen[origin] = struct{}{} + return hasResultType(a.Attribute(), seen) + case *expr.Array: + return hasResultType(a.ElemType, seen) + case *expr.Map: + return hasResultType(a.KeyType, seen) || hasResultType(a.ElemType, seen) + case *expr.Object: + for _, nat := range *a { + if hasResultType(nat.Attribute, seen) { + return true + } + } + case *expr.Union: + for _, nat := range a.Values { + if hasResultType(nat.Attribute, seen) { + return true + } + } + } + return false +} + +// buildProjectedType returns render data for one view-specific declaration +// whose fields use pointers, plus conversions to and from the source service +// type. +func buildProjectedType(facts *projectedTypeFacts, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration, viewsPkg string) *ProjectedTypeData { + var ( + projections []*InitData + typeInits []*InitData + views []*ViewData + + varname = declaration.Name() + pt = facts.projectedType + ) + if facts.resultType { + typeInits = buildViewConversions(facts, serviceResolver, viewResolver, true) + projections = buildViewConversions(facts, serviceResolver, viewResolver, false) + serviceName := facts.source.Link( + serviceResolver.outputPath, + retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath), + ).Name() + views = buildViews(facts.views, serviceName, facts.mapDeclaration, facts.conversions) + } + validations := buildValidations(facts, viewResolver) + linked := facts.projected.Link(viewResolver.outputPath, retainedTypeQualifier(viewResolver.aliases, viewResolver.outputPath)) + definition := facts.definition.Link(viewResolver.outputPath, retainedTypeQualifier(viewResolver.aliases, viewResolver.outputPath)) + return &ProjectedTypeData{ + UserTypeData: &UserTypeData{ + Declaration: declaration, + Name: varname, + Description: fmt.Sprintf("%s is a type that runs validations on a projected type.", varname), + VarName: varname, + Def: definition.Def(), + Ref: linked.Ref(), + Type: pt, + }, + Projections: projections, + TypeInits: typeInits, + Validations: validations, + ViewsPkg: viewsPkg, + Views: views, + } +} + +// buildViews builds the view data for all the views in the given result type. +func buildViews(facts []*viewRenderFacts, typeName string, mapDeclaration *codegen.NameDeclaration, conversions []*viewConversionFacts) []*ViewData { + toProjected := make(map[string]*codegen.NameDeclaration) + toResult := make(map[string]*codegen.NameDeclaration) + for _, conversion := range conversions { + calls := toProjected + if conversion.toResult { + calls = toResult + } + calls[canonicalValidatorView(conversion.viewName)] = conversion.constructor + } + views := make([]*ViewData, len(facts)) + for i, view := range facts { + views[i] = &ViewData{ + Name: view.name, + Description: view.description, + Attributes: append([]string(nil), view.attributes...), + TypeVarName: typeName, + MapDeclaration: mapDeclaration, + ToProjected: toProjected[canonicalValidatorView(view.name)], + ToResult: toResult[canonicalValidatorView(view.name)], + } + } + return views +} + +// buildViewedResultType formats the viewed-result wrapper copied during +// planning and its constructors without rereading the mutable design +// expression. +func buildViewedResultType(facts *viewedResultFacts, viewspkg string, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration) *ViewedResultTypeData { + isarr := facts.isCollection + viewName := facts.viewName + views := buildViews(facts.views, declaration.Name(), facts.mapDeclaration, facts.conversions) + + // build validation data + qualifier := retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath) + serviceType := facts.source.layout.Link(serviceResolver.outputPath, qualifier) + resvar, serviceRef := declaration.Name(), serviceType.Ref() + projT := facts.wrapped + wrapperViewType := facts.wrappedLayout.Link(viewResolver.outputPath, qualifier) + resref := wrapperViewType.Name() + if !isarr { + resref = "*" + resref + } + validationCalls := make([]*ValidationCallData, len(facts.views)) + for index, view := range facts.views { + validationCalls[index] = newRetainedValidationCall(facts.validationCalls[index], view.name) + } + data := map[string]any{ + "ArgVar": "result", + "Source": "result", + "ValidationCalls": validationCalls, + "IsViewed": true, + } + buf := &bytes.Buffer{} + if err := validateTypeCodeTmpl.Execute(buf, data); err != nil { + panic(err) // bug + } + validatorDeclaration := facts.validator + name := validatorDeclaration.Name() + validate := &ValidateData{ + Declaration: validatorDeclaration, + Name: validatorDeclaration.Name(), + Description: fmt.Sprintf("%s runs the validations defined on the viewed result type %s.", name, resvar), + Ref: resref, + Validate: buf.String(), + Calls: validationCalls, + } + + // build constructor to initialize viewed result type from result type + wrapperServiceType := facts.wrappedLayout.Link(serviceResolver.outputPath, qualifier) + vresref := wrapperServiceType.Name() + if !isarr { + vresref = "*" + vresref + } + data = map[string]any{ + "ToViewed": true, + "ArgVar": "res", + "ReturnVar": "vres", + "Views": views, + "ReturnTypeRef": vresref, + "IsCollection": isarr, + "TargetType": wrapperServiceType.Name(), + } + buf = &bytes.Buffer{} + if err := initTypeCodeTmpl.Execute(buf, data); err != nil { + panic(err) // bug + } + name = facts.toViewed.Name() + init := &InitData{ + Declaration: facts.toViewed, + Name: facts.toViewed.Name(), + Description: fmt.Sprintf("%s initializes viewed result type %s from result type %s using the given view.", name, resvar, resvar), + Args: []*InitArgData{ + {Name: "res", Ref: serviceRef}, + {Name: "view", Ref: "string"}, + }, + ReturnTypeRef: vresref, + Code: buf.String(), + } + + // build constructor to initialize result type from viewed result type + resref = serviceRef + data = map[string]any{ + "ToResult": true, + "ArgVar": "vres", + "ReturnVar": "res", + "Views": views, + "ReturnTypeRef": resref, + } + buf = &bytes.Buffer{} + if err := initTypeCodeTmpl.Execute(buf, data); err != nil { + panic(err) // bug + } + name = facts.toResult.Name() + resinit := &InitData{ + Declaration: facts.toResult, + Name: facts.toResult.Name(), + Description: fmt.Sprintf("%s initializes result type %s from viewed result type %s.", name, resvar, resvar), + Args: []*InitArgData{{Name: "vres", Ref: vresref}}, + ReturnTypeRef: resref, + Code: buf.String(), + } + + return &ViewedResultTypeData{ + UserTypeData: &UserTypeData{ + Declaration: declaration, + Name: resvar, + Description: fmt.Sprintf("%s is the viewed result type that is projected based on a view.", resvar), + VarName: resvar, + Def: facts.wrappedDef.Link(viewResolver.outputPath, qualifier).Def(), + Ref: resref, + Type: projT, + }, + FullName: wrapperServiceType.Name(), + FullRef: vresref, + ResultInit: resinit, + Init: init, + Views: views, + Validate: validate, + IsCollection: isarr, + ViewName: viewName, + ViewsPkg: viewspkg, + } +} + +// wrapProjected builds a viewed result type with two fields: "projected" holds +// the supplied view-specific result, and "view" records the selected view name. +func wrapProjected(projected expr.UserType) expr.UserType { + rt := projected.(*expr.ResultTypeExpr) + pratt := &expr.NamedAttributeExpr{ + Name: "projected", + Attribute: &expr.AttributeExpr{Type: rt, Description: "Type to project"}, + } + prview := &expr.NamedAttributeExpr{ + Name: "view", + Attribute: &expr.AttributeExpr{Type: expr.String, Description: "View to render"}, + } + return &expr.ResultTypeExpr{ + UserTypeExpr: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{pratt, prview}, + Validation: &expr.ValidationExpr{Required: []string{"projected", "view"}}, + }, + TypeName: rt.TypeName, + }, + Identifier: rt.Identifier, + Views: rt.Views, + } +} + +// buildViewConversions builds one constructor per view to convert between a +// complete service result and the result fields selected by that view. When +// toResult is true, each constructor rebuilds the service result. Otherwise it +// copies only the selected view fields from the service result. +func buildViewConversions(facts *projectedTypeFacts, serviceResolver, viewResolver *declarationResolver, toResult bool) []*InitData { + init := make([]*InitData, 0, len(facts.conversions)/2) + serviceType := facts.source.Link(serviceResolver.outputPath, retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath)) + serviceName, serviceRef := serviceType.Name(), serviceType.Ref() + projectedDeclaration := facts.declaration + projectedRef := facts.projected.Link(serviceResolver.outputPath, retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath)).Ref() + serviceViewResolver := viewResolver.withOutputPackage(serviceResolver.outputPath) + for _, conversion := range facts.conversions { + if conversion.toResult != toResult { + continue + } + viewedResolver := serviceViewResolver.bindDerived(conversion.contextType, conversion.contextIdentity) + if conversion.elementType != nil { + viewedResolver = viewedResolver.bindDerived(conversion.elementType, conversion.elementIdentity) + } + targetType := conversion.targetLayout.Link( + serviceResolver.outputPath, + retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath), + ).Name() + if toResult { + srcCtx := declarationContext(viewedResolver, true) + tgtCtx := declarationContext(serviceResolver, false) + resvar := serviceName + name := conversion.constructor.Name() + code, helpers := buildConstructorCode( + conversion, + "vres", + "res", + srcCtx, + tgtCtx, + targetType, + ) + init = append(init, &InitData{ + Declaration: conversion.constructor, + Name: conversion.constructor.Name(), + Description: fmt.Sprintf("%s converts projected type %s to service type %s.", name, resvar, resvar), + Args: []*InitArgData{{Name: "vres", Ref: projectedRef}}, + ReturnTypeRef: serviceRef, + Code: code, + Helpers: helpers, + }) + } else { + srcCtx := declarationContext(serviceResolver, false) + tgtCtx := declarationContext(viewedResolver, true) + tname := projectedDeclaration.Name() + name := conversion.constructor.Name() + code, helpers := buildConstructorCode( + conversion, + "res", + "vres", + srcCtx, + tgtCtx, + targetType, + ) + init = append(init, &InitData{ + Declaration: conversion.constructor, + Name: conversion.constructor.Name(), + Description: fmt.Sprintf("%s projects result type %s to projected type %s using the %q view.", name, serviceName, tname, conversion.viewName), + Args: []*InitArgData{{Name: "res", Ref: serviceRef}}, + ReturnTypeRef: projectedRef, + Code: code, + Helpers: helpers, + }) + } + } + return init +} + +// buildValidations builds the data required to validate result types containing +// only the fields in their selected views. +func buildValidations(projected *projectedTypeFacts, resolver *declarationResolver) []*ValidateData { + linkedType := projected.projected.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)) + tname := linkedType.Name() + var validations []*ValidateData + if projected.resultType { + // for result types we create a validation function containing view + // specific validation logic for each view + for _, facts := range projected.validations { + if !facts.needed { + continue + } + viewName := facts.viewName + data := map[string]any{ + "Projected": tname, + "ArgVar": "result", + "Source": "result", + "IsCollection": facts.collectionElem != nil, + } + declaration := facts.declaration + name := declaration.Name() + var calls []*ValidationCallData + + if facts.collectionElem != nil { + // dealing with an array type + data["Source"] = "item" + call := newRetainedValidationCall(facts.collectionCall, viewName) + data["ValidateCall"] = call + calls = append(calls, call) + } else { + fields := make([]*validationFieldData, 0, len(facts.fields)) + for _, field := range facts.fields { + if !field.required && field.call == nil { + continue + } + var call *ValidationCallData + if field.call != nil { + call = newRetainedValidationCall(field.call, field.view) + } + fields = append(fields, &validationFieldData{ + Name: field.name, + Call: call, + IsRequired: field.required, + }) + if call != nil { + calls = append(calls, call) + } + } + data["Validate"] = renderRetainedValidation(facts, resolver) + data["Fields"] = fields + } + + buf := &bytes.Buffer{} + if err := validateTypeCodeTmpl.Execute(buf, data); err != nil { + panic(err) // bug + } + + validations = append(validations, &ValidateData{ + Declaration: declaration, + Name: declaration.Name(), + Description: fmt.Sprintf("%s runs the validations defined on %s using the %q view.", name, tname, viewName), + Ref: linkedType.Ref(), + Validate: buf.String(), + Calls: calls, + }) + } + } else { + // for a user type or a result type with single view, we generate only one validation + // function containing the validation logic + facts := projected.validations[0] + if !facts.needed { + return nil + } + declaration := facts.declaration + name := declaration.Name() + validations = append(validations, &ValidateData{ + Declaration: declaration, + Name: declaration.Name(), + Description: fmt.Sprintf("%s runs the validations defined on %s.", name, tname), + Ref: linkedType.Ref(), + Validate: renderRetainedValidation(facts, resolver), + }) + } + return validations +} + +// This helper formats a saved validation plan using the completed declarations +// and import names from the views package. +func renderRetainedValidation(facts *validationFacts, resolver *declarationResolver) string { + linkedLayout := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)) + linked, err := facts.plan.Link(linkedLayout) + if err != nil { + panic(err) // bug + } + return linked.Render("result", "result") +} + +// This helper formats one nested validation call from the exact function +// declaration recorded during planning. +func newRetainedValidationCall(declaration *codegen.NameDeclaration, view string) *ValidationCallData { + return &ValidationCallData{ + Declaration: declaration, + View: view, + Default: canonicalValidatorView(view) == "", + } +} + +// buildConstructorCode builds code that copies fields between a complete +// service result and a result containing only one view's fields. +// +// sourceCtx and targetCtx provide the package names, pointer rules, and field +// names used to read the source value and write the target value. +// +// sourceVar and targetVar contains the variable name that holds the source and +// target data structures in the transformation code. +// +// view is used to generate the constructor function name. +func buildConstructorCode(facts *viewConversionFacts, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext, targetType string) (string, []*codegen.TransformFunctionData) { + var ( + helpers []*codegen.TransformFunctionData + buf bytes.Buffer + ) + data := map[string]any{ + "ArgVar": sourceVar, + "ReturnVar": targetVar, + "IsCollection": facts.collection, + "TargetType": targetType, + } + + if facts.collection { + // result type collection + data["Init"] = facts.elementCall + if err := initTypeCodeTmpl.Execute(&buf, data); err != nil { + panic(err) // bug + } + return buf.String(), helpers + } + + data["Source"] = sourceVar + data["Target"] = targetVar + + if err := facts.plan.BindContexts(sourceCtx, targetCtx); err != nil { + panic(err) // bug + } + code, helpers, err := facts.plan.Render(sourceVar, targetVar, true) + if err != nil { + panic(err) // bug + } + data["Code"] = code + + fields := make([]*constructorFieldData, 0, len(facts.fields)) + for _, field := range facts.fields { + fields = append(fields, &constructorFieldData{ + VarName: codegen.Goify(field.name, true), + Declaration: field.call, + }) + } + data["Fields"] = fields + + if err := initTypeCodeTmpl.Execute(&buf, data); err != nil { + panic(err) // bug + } + return buf.String(), helpers +} + +// walkViewAttrs iterates through the attributes in att that are found in the +// given view and executes the walker function. +func walkViewAttrs(obj *expr.Object, view *expr.ViewExpr, walker func(name string, attr, vatt *expr.AttributeExpr)) { + for _, nat := range *expr.AsObject(view.Type) { + if attr := obj.Attribute(nat.Name); attr != nil { + walker(nat.Name, attr, nat.Attribute) + } + } +} + +// removeMeta removes the meta attributes from the given attribute. This is +// needed to make sure that any field name overriding is removed when +// generating protobuf types (as protogen itself won't honor these overrides). +func removeMeta(att *expr.AttributeExpr) { + if err := codegen.Walk(att, func(a *expr.AttributeExpr) error { + delete(a.Meta, "struct:pkg:path") + return nil + }); err != nil { + panic(err) // bug + } +} diff --git a/codegen/service/view_validation_plan.go b/codegen/service/view_validation_plan.go new file mode 100644 index 0000000000..e476854a76 --- /dev/null +++ b/codegen/service/view_validation_plan.go @@ -0,0 +1,253 @@ +// This file records validation for result types narrowed to their declared +// views. Each field check and child call uses the Go type and function +// declarations submitted for the generated views package. +package service + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// viewValidationPolicy states that generated view fields use pointers, apply +// defaults, and represent Goa OneOf values with generated structs. Both type +// layout and validation use these same choices. +func viewValidationPolicy() codegen.GoLayoutPolicy { + return codegen.GoLayoutPolicy{ + Pointer: true, + UseDefault: true, + SumType: true, + } +} + +// planServiceValidations records every field check and child validation call +// written for service result views after all view type names are submitted. +func planServiceValidations(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + hasProjection := false + for _, method := range facts.orderedMethods { + hasProjection = hasProjection || method.projection != nil + } + if !hasProjection { + return nil + } + views := generation.Package(facts.viewsPath) + derived := make(map[expr.UserType]*codegen.TypeDeclaration) + wrappers := make(map[expr.UserType]*codegen.TypeDeclaration) + for _, method := range facts.orderedMethods { + if method.viewedResult != nil { + wrappers[method.viewedResult.wrapped] = method.viewedResult.declaration + } + if method.projection == nil { + continue + } + for _, projected := range method.projection.types { + declaration, err := views.DerivedType(codegen.NewProjectedTypeID(projected.pair.source)) + if err != nil { + return err + } + derived[projected.pair.projected.Origin()] = declaration + } + } + binder := func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + switch request.Kind { + case codegen.GoNamed: + userType := request.Attribute.Type.(expr.UserType) + declaration := wrappers[userType] + if declaration == nil { + declaration = derived[userType.Origin()] + } + if declaration == nil { + var err error + declaration, err = views.Type(userType) + if err != nil { + return codegen.GoTypeBinding{}, err + } + } + return codegen.GoTypeBinding{Owner: facts.viewsPath, Type: declaration}, nil + case codegen.GoUnion: + declaration, err := views.Union(request.Attribute.Type.(*expr.Union)) + if err != nil { + return codegen.GoTypeBinding{}, err + } + return codegen.GoTypeBinding{Owner: facts.viewsPath, Union: declaration}, nil + default: + return codegen.GoTypeBinding{}, fmt.Errorf("bind unsupported view validation type %s", request.Kind) + } + } + planLayout := func(attribute *expr.AttributeExpr, pointer bool) (*codegen.GoTypePlan, error) { + policy := viewValidationPolicy() + policy.Pointer = pointer + return codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ + Owner: facts.viewsPath, + Policy: policy, + Bind: binder, + }) + } + validator := func(request codegen.ValidatorBindingRequest) (*codegen.NameDeclaration, error) { + declaration := request.Layout.TypeDeclaration() + retained := facts.validators[validatorKey{ + declaration: declaration, + view: canonicalValidatorView(request.View), + }] + if retained == nil { + return nil, fmt.Errorf( + "validator for declaration %p and view %q was not retained", + declaration, + request.View, + ) + } + return retained, nil + } + validatorCall := func(attribute *expr.AttributeExpr, view string, required bool) (*codegen.NameDeclaration, error) { + layout, err := planLayout(attribute, true) + if err != nil { + return nil, err + } + request := codegen.ValidatorBindingRequest{ + Attribute: attribute, + Layout: layout, + View: view, + } + declaration := facts.validators[validatorKey{ + declaration: layout.TypeDeclaration(), + view: canonicalValidatorView(view), + }] + if declaration == nil && required { + return validator(request) + } + return declaration, nil + } + for _, method := range facts.orderedMethods { + if method.projection == nil { + continue + } + for _, projected := range method.projection.types { + projected.resultType = len(projected.views) > 0 + layout, err := planLayout(projected.pair.projectedAttribute, true) + if err != nil { + return err + } + definition, err := planLayout(projected.pair.projected.Attribute(), true) + if err != nil { + return err + } + source, err := codegen.PlanGoType(projected.pair.sourceAttribute, codegen.GoTypePlanOptions{ + Owner: facts.packagePath, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: serviceGoTypeBinder(rootTypes, generation), + }) + if err != nil { + return err + } + projected.projected = layout + projected.definition = definition + projected.source = source + for _, conversion := range projected.conversions { + conversion.collection = expr.AsArray(conversion.target.Type) != nil + conversionBinder := binder + conversionOwner := facts.viewsPath + if conversion.toResult { + conversionBinder = serviceGoTypeBinder(rootTypes, generation) + conversionOwner = facts.packagePath + } else { + viewBinder := conversionBinder + targetType := conversion.target.Type.(expr.UserType) + conversionBinder = func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + if request.Kind == codegen.GoNamed && request.Attribute.Type == targetType { + return codegen.GoTypeBinding{Owner: facts.viewsPath, Type: projected.declaration}, nil + } + return viewBinder(request) + } + } + conversion.targetLayout, err = codegen.PlanGoType(conversion.target, codegen.GoTypePlanOptions{ + Owner: conversionOwner, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: conversionBinder, + }) + if err != nil { + return err + } + context := conversion.target + if conversion.toResult { + context = conversion.source + } + conversion.contextType = context.Type.(expr.UserType) + conversion.contextIdentity = codegen.NewProjectedTypeID(projected.pair.source) + if projectedArray := expr.AsArray(projected.pair.projectedAttribute.Type); projectedArray != nil { + conversion.elementType = expr.AsArray(context.Type).ElemType.Type.(expr.UserType) + conversion.elementIdentity = codegen.NewProjectedTypeID( + expr.AsArray(projected.pair.sourceAttribute.Type).ElemType.Type.(expr.UserType), + ) + } + } + for _, validation := range projected.validations { + if !validation.needed { + continue + } + if validation.collectionElem != nil { + declaration, err := validatorCall(validation.collectionElem, validation.viewName, true) + if err != nil { + return err + } + validation.collectionCall = declaration + continue + } + layout, err := planLayout(validation.attribute, validation.pointer) + if err != nil { + return err + } + plan, err := codegen.NewValidationPlan( + validation.attribute, + layout, + codegen.ValidationPlanOptions{ + Required: true, + Alias: validation.alias, + Bind: validator, + }, + ) + if err != nil { + return err + } + validation.layout = layout + validation.plan = plan + for _, field := range validation.fields { + declaration, err := validatorCall(field.attribute, field.view, false) + if err != nil { + return err + } + field.call = declaration + } + } + } + viewed := method.viewedResult + if viewed == nil { + continue + } + wrapped, err := planLayout(&expr.AttributeExpr{Type: viewed.wrapped}, false) + if err != nil { + return err + } + if wrapped.TypeDeclaration() != viewed.declaration { + return fmt.Errorf("viewed result %q layout was bound to the wrong declaration", viewed.wrapped.Name()) + } + wrappedDef, err := planLayout(viewed.wrapped.Attribute(), false) + if err != nil { + return err + } + viewed.wrappedLayout = wrapped + viewed.wrappedDef = wrappedDef + } + for _, union := range facts.viewUnions { + if err := planUnionRenderFacts(union, binder, views); err != nil { + return err + } + } + return nil +} diff --git a/codegen/service/views.go b/codegen/service/views.go index 3c074edbb6..9072dad73b 100644 --- a/codegen/service/views.go +++ b/codegen/service/views.go @@ -1,61 +1,32 @@ +// This file renders result types containing only the fields in selected views, +// their viewed-result wrappers, and any unions those declarations require. package service import ( "path/filepath" - "sort" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) type viewedType struct { - // Name is the type name. - Name string + // Declaration is the exact package-level view map rendered for this type. + Declaration *codegen.NameDeclaration + // TypeName is the generated type whose fields the map indexes. + TypeName string // Views is the view data for all views defined in the type. Views []*ViewData } -// ViewsFile returns the views file for the given service which contains -// logic to render result types using the defined views. -func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { - svc := services.Get(service.Name) +// viewsFile renders views from the service data copied into plan. +func viewsFile(plan *Plan, facts *serviceFacts) *codegen.File { + services := plan.Services() + svc := services.Get(facts.name) if len(svc.projectedTypes) == 0 { return nil } - // Collect union sum-type definitions for the views package. - // - // View-projected types cannot import the service package (which already - // depends on views), therefore unions must be generated in the views package - // when referenced by projected types. - unionByHash := make(map[string]*UnionTypeData) - seenUnions := make(map[string]struct{}) - viewLoc := &codegen.Location{RelImportPath: "views"} - for _, t := range svc.projectedTypes { - collectUnionTypes(&expr.AttributeExpr{Type: t.Type}, svc.ViewScope, viewLoc, unionByHash, seenUnions, true) - } - unions := make([]*UnionTypeData, 0, len(unionByHash)) - for _, u := range unionByHash { - unions = append(unions, u) - } - sort.Slice(unions, func(i, j int) bool { - return unions[i].Name < unions[j].Name - }) - path := filepath.Join(codegen.Gendir, svc.PathName, "views", "view.go") - imports := []*codegen.ImportSpec{ - codegen.GoaImport(""), - {Path: "unicode/utf8"}, - } - if len(unions) > 0 { - imports = append(imports, - codegen.SimpleImport("bytes"), - codegen.SimpleImport("encoding/json"), - codegen.SimpleImport("fmt"), - ) - } - header := codegen.Header(service.Name+" views", "views", - imports) + header := codegen.Header(facts.name+" views", "views", facts.imports.views.specs) sections := []*codegen.SectionTemplate{header} // type definitions @@ -73,7 +44,7 @@ func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *cod Data: t.UserTypeData, }) } - for _, u := range unions { + for _, u := range svc.viewUnions { sections = append(sections, &codegen.SectionTemplate{ Name: "projected-union-type", Source: serviceTemplates.Read(unionTypeT), @@ -85,23 +56,31 @@ func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *cod // rendered in the view as value. var ( rtdata []*viewedType - seen = make(map[string]struct{}) + seen = make(map[*codegen.NameDeclaration]struct{}) ) for _, t := range svc.viewedResultTypes { - name := t.Views[0].TypeVarName - if _, ok := seen[name]; !ok { - rtdata = append(rtdata, &viewedType{Name: name, Views: t.Views}) - seen[name] = struct{}{} + declaration := t.Views[0].MapDeclaration + if _, ok := seen[declaration]; !ok { + rtdata = append(rtdata, &viewedType{ + Declaration: declaration, + TypeName: t.Views[0].TypeVarName, + Views: t.Views, + }) + seen[declaration] = struct{}{} } } for _, t := range svc.projectedTypes { if len(t.Views) == 0 { continue } - name := t.Views[0].TypeVarName - if _, ok := seen[name]; !ok { - rtdata = append(rtdata, &viewedType{Name: name, Views: t.Views}) - seen[name] = struct{}{} + declaration := t.Views[0].MapDeclaration + if _, ok := seen[declaration]; !ok { + rtdata = append(rtdata, &viewedType{ + Declaration: declaration, + TypeName: t.Views[0].TypeVarName, + Views: t.Views, + }) + seen[declaration] = struct{}{} } } sections = append(sections, &codegen.SectionTemplate{ diff --git a/codegen/service/views_test.go b/codegen/service/views_test.go index 5a1b50bf8f..767b83f6f9 100644 --- a/codegen/service/views_test.go +++ b/codegen/service/views_test.go @@ -1,8 +1,16 @@ +// This file verifies generated view declarations, validators, and converters. package service import ( "bytes" + "flag" + "go/ast" "go/format" + "go/parser" + "go/token" + "os" + "slices" + "strconv" "strings" "testing" @@ -13,30 +21,34 @@ import ( "goa.design/goa/v3/codegen/service/testdata" ) +var updateViewGolden = flag.Bool("update-views", false, "update view code expectations") + func TestViews(t *testing.T) { cases := []struct { - Name string - DSL func() - Code string + Name string + Constant string + DSL func() + Code string }{ - {"result-with-multiple-views", testdata.ResultWithMultipleViewsDSL, testdata.ResultWithMultipleViewsCode}, - {"result-collection-multiple-views", testdata.ResultCollectionMultipleViewsDSL, testdata.ResultCollectionMultipleViewsCode}, - {"result-with-user-type", testdata.ResultWithUserTypeDSL, testdata.ResultWithUserTypeCode}, - {"result-with-result-type", testdata.ResultWithResultTypeDSL, testdata.ResultWithResultTypeCode}, - {"result-with-recursive-result-type", testdata.ResultWithRecursiveResultTypeDSL, testdata.ResultWithRecursiveResultTypeCode}, - {"result-type-with-custom-fields", testdata.ResultWithCustomFieldsDSL, testdata.ResultWithCustomFieldsCode}, - {"result-with-recursive-collection-of-result-type", testdata.ResultWithRecursiveCollectionOfResultTypeDSL, testdata.ResultWithRecursiveCollectionOfResultTypeCode}, - {"result-with-multiple-methods", testdata.ResultWithMultipleMethodsDSL, testdata.ResultWithMultipleMethodsCode}, - {"result-with-enum-type", testdata.ResultWithEnumTypeDSL, testdata.ResultWithEnumType}, - {"result-with-pkg-path", testdata.ResultWithPkgPathDSL, testdata.ResultWithPkgPathCode}, - {"result-with-oneof-in-result-type", testdata.ResultWithOneOfInResultTypeDSL, testdata.ResultWithOneOfInResultTypeCode}, + {"result-with-multiple-views", "ResultWithMultipleViewsCode", testdata.ResultWithMultipleViewsDSL, testdata.ResultWithMultipleViewsCode}, + {"result-collection-multiple-views", "ResultCollectionMultipleViewsCode", testdata.ResultCollectionMultipleViewsDSL, testdata.ResultCollectionMultipleViewsCode}, + {"result-with-user-type", "ResultWithUserTypeCode", testdata.ResultWithUserTypeDSL, testdata.ResultWithUserTypeCode}, + {"result-with-result-type", "ResultWithResultTypeCode", testdata.ResultWithResultTypeDSL, testdata.ResultWithResultTypeCode}, + {"result-with-recursive-result-type", "ResultWithRecursiveResultTypeCode", testdata.ResultWithRecursiveResultTypeDSL, testdata.ResultWithRecursiveResultTypeCode}, + {"result-type-with-custom-fields", "ResultWithCustomFieldsCode", testdata.ResultWithCustomFieldsDSL, testdata.ResultWithCustomFieldsCode}, + {"result-with-recursive-collection-of-result-type", "ResultWithRecursiveCollectionOfResultTypeCode", testdata.ResultWithRecursiveCollectionOfResultTypeDSL, testdata.ResultWithRecursiveCollectionOfResultTypeCode}, + {"result-with-multiple-methods", "ResultWithMultipleMethodsCode", testdata.ResultWithMultipleMethodsDSL, testdata.ResultWithMultipleMethodsCode}, + {"result-with-enum-type", "ResultWithEnumType", testdata.ResultWithEnumTypeDSL, testdata.ResultWithEnumType}, + {"result-with-pkg-path", "ResultWithPkgPathCode", testdata.ResultWithPkgPathDSL, testdata.ResultWithPkgPathCode}, + {"result-with-oneof-in-result-type", "ResultWithOneOfInResultTypeCode", testdata.ResultWithOneOfInResultTypeDSL, testdata.ResultWithOneOfInResultTypeCode}, } + updates := make(map[string]string, len(cases)) for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := ViewsFile("goa.design/goa/example", root.Services[0], services) + fs := viewsFile(plan, plan.facts.services[0]) require.NotNil(t, fs) buf := new(bytes.Buffer) for _, s := range fs.SectionTemplates[1:] { @@ -46,7 +58,70 @@ func TestViews(t *testing.T) { require.NoError(t, err, buf.String()) code := string(bs) code = strings.ReplaceAll(code, "\r\n", "\n") + if *updateViewGolden { + updates[c.Constant] = code + return + } assert.Equal(t, c.Code, code) }) } + if *updateViewGolden { + updateViewCodeExpectations(t, updates) + } +} + +// updateViewCodeExpectations replaces only the named string literals and +// leaves the surrounding test fixtures unchanged. +func updateViewCodeExpectations(t *testing.T, updates map[string]string) { + t.Helper() + path := "testdata/views_code.go" + source, err := os.ReadFile(path) + require.NoError(t, err) + files := token.NewFileSet() + parsed, err := parser.ParseFile(files, path, source, 0) + require.NoError(t, err) + type replacement struct { + start int + end int + value string + } + var replacements []replacement + for _, declaration := range parsed.Decls { + generic, ok := declaration.(*ast.GenDecl) + if !ok || generic.Tok != token.CONST { + continue + } + for _, specification := range generic.Specs { + value := specification.(*ast.ValueSpec) + for index, name := range value.Names { + updated, exists := updates[name.Name] + if !exists { + continue + } + literal := value.Values[index].(*ast.BasicLit) + replacements = append(replacements, replacement{ + start: files.Position(literal.Pos()).Offset, + end: files.Position(literal.End()).Offset, + value: viewCodeLiteral(updated), + }) + } + } + } + require.Len(t, replacements, len(updates)) + slices.SortFunc(replacements, func(left, right replacement) int { + return right.start - left.start + }) + for _, replacement := range replacements { + source = append(source[:replacement.start], append([]byte(replacement.value), source[replacement.end:]...)...) + } + require.NoError(t, os.WriteFile(path, source, 0o644)) +} + +// viewCodeLiteral keeps readable raw strings unless generated Go tags require +// an interpreted string. +func viewCodeLiteral(source string) string { + if !strings.Contains(source, "`") { + return "`" + source + "`" + } + return strconv.Quote(source) } diff --git a/codegen/templates/transform_go_array.go.tpl b/codegen/templates/transform_go_array.go.tpl index 99119fd38a..a0c35ff259 100644 --- a/codegen/templates/transform_go_array.go.tpl +++ b/codegen/templates/transform_go_array.go.tpl @@ -1,12 +1,24 @@ -{{ .TargetVar }} {{ if .NewVar }}:={{ else }}={{ end }} make({{ if .TypeAliasName }}{{ .TypeAliasName }}{{ else }}[]{{ .ElemTypeRef }}{{ end }}, len({{ .SourceVar }})) +{{ .TargetVar }} {{ if .NewVar }}:={{ else }}={{ end }} make({{ if .TypeAliasName }}{{ .TypeAliasName }}{{ else }}[]{{ if .TargetElemPointer }}*{{ end }}{{ .ElemTypeRef }}{{ end }}, len({{ .SourceVar }})) for {{ .LoopVar }}, val := range {{ .SourceVar }} { -{{ if .IsStruct -}} +{{ if .SourceIsObject -}} if val == nil { {{ .TargetVar }}[{{ .LoopVar }}] = nil continue } - {{ .TargetVar }}[{{ .LoopVar }}] = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs }}(val) +{{ end -}} +{{ if .TargetElemPointer -}} + var transformed {{ .ElemTypeRef }} +{{ if .UseHelper -}} + transformed = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs }}({{ .SourceElement }}) +{{ else -}} + {{ transformAttribute .SourceElem .TargetElem .SourceElement "transformed" false .TransformAttrs -}} +{{ end -}} + {{ .TargetVar }}[{{ .LoopVar }}] = &transformed {{ else -}} - {{ transformAttribute .SourceElem .TargetElem "val" (printf "%s[%s]" .TargetVar .LoopVar) false .TransformAttrs -}} +{{ if .UseHelper -}} + {{ .TargetVar }}[{{ .LoopVar }}] = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs }}({{ .SourceElement }}) +{{ else -}} + {{ transformAttribute .SourceElem .TargetElem .SourceElement (printf "%s[%s]" .TargetVar .LoopVar) false .TransformAttrs -}} +{{ end -}} {{ end -}} } diff --git a/codegen/templates/transform_go_map.go.tpl b/codegen/templates/transform_go_map.go.tpl index cc4715a029..ff646fd463 100644 --- a/codegen/templates/transform_go_map.go.tpl +++ b/codegen/templates/transform_go_map.go.tpl @@ -1,14 +1,16 @@ {{ .TargetVar }} {{ if .NewVar }}:={{ else }}={{ end }} make({{ if .TypeAliasName }}{{ .TypeAliasName }}{{ else }}map[{{ .KeyTypeRef }}]{{ .ElemTypeRef }}{{ end }}, len({{ .SourceVar }})) for key, val := range {{ .SourceVar }} { -{{ if .IsKeyStruct -}} - tk := {{ transformHelperName .SourceKey .TargetKey .TransformAttrs -}}(val) +{{ if .UseKeyHelper -}} + tk := {{ transformHelperName .SourceKey .TargetKey .TransformAttrs -}}(key) {{ else -}} {{ transformAttribute .SourceKey .TargetKey "key" "tk" true .TransformAttrs }}{{ end -}} -{{ if .IsElemStruct -}} +{{ if .ElemIsObject -}} if val == nil { {{ .TargetVar }}[tk] = nil continue } +{{ end -}} +{{ if .UseElemHelper -}} {{ .TargetVar }}[tk] = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs -}}(val) {{ else -}} {{ transformAttribute .SourceElem .TargetElem "val" (printf "tv%s" .LoopVar) true .TransformAttrs -}} diff --git a/codegen/templates/transform_go_union.go.tpl b/codegen/templates/transform_go_union.go.tpl index 81d7bde5bb..1ca58a0131 100644 --- a/codegen/templates/transform_go_union.go.tpl +++ b/codegen/templates/transform_go_union.go.tpl @@ -4,20 +4,27 @@ switch string({{ .SourceVar }}.Kind()) { {{- range .Cases }} case {{ printf "%q" .CaseName }}: actual, _ := {{ $.SourceVar }}.As{{ .SourceFieldName }}() + {{- if .SourceNilable }} + var {{ $.TempVarName }} {{ .TargetCastType }} + if actual != nil { + {{- if .UseHelper }} + {{ $.TempVarName }} = {{ .HelperName }}(actual) + {{- else }} + {{ transformAttribute .SourceAttr .TargetAttr "actual" $.TempVarName false $.TransformAttrs -}} + {{- end }} + } + {{- else }} {{- if .UseHelper }} {{ $.TempVarName }} := {{ .HelperName }}(actual) {{- else }} {{ transformAttribute .SourceAttr .TargetAttr "actual" $.TempVarName true $.TransformAttrs -}} {{- end }} + {{- end }} {{- if $.NewVar }} var u {{ $.ValueTypeRef }} u.Set{{ .TargetFieldName }}(({{ .TargetCastType }})({{ $.TempVarName }})) - {{- if $.TargetIsPointer }} {{ $.TargetVar }} = &u {{- else }} - {{ $.TargetVar }} = u - {{- end }} - {{- else }} u := {{ $.TargetVar }} u.Set{{ .TargetFieldName }}(({{ .TargetCastType }})({{ $.TempVarName }})) {{ $.TargetVar }} = u diff --git a/codegen/templates/validation/array.go.tpl b/codegen/templates/validation/array.go.tpl index 2332fa52b1..a9672c3343 100644 --- a/codegen/templates/validation/array.go.tpl +++ b/codegen/templates/validation/array.go.tpl @@ -1,10 +1,11 @@ for _, e := range {{ .target }} { -{{- if .nonNullableElems }} +{{- if .checkNilElements }} if e == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("{{ .context }}", "[*]")) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.MissingFieldError({{ validationPath .context }}, "[*]")) } {{- end }} {{- if .validation }} {{ .validation }} {{- end }} -} \ No newline at end of file +} +{{- "" -}} diff --git a/codegen/templates/validation/enum.go.tpl b/codegen/templates/validation/enum.go.tpl index 4238f7691c..426d2a6186 100644 --- a/codegen/templates/validation/enum.go.tpl +++ b/codegen/templates/validation/enum.go.tpl @@ -1,8 +1,8 @@ {{ if .isPointer }}if {{ .target }} != nil { {{ end -}} if !({{ oneof .targetVal .values }}) { - err = goa.MergeErrors(err, goa.InvalidEnumValueError({{ printf "%q" .context }}, {{ .targetVal }}, {{ slice .values }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.InvalidEnumValueError({{ validationPath .context }}, {{ .targetVal }}, {{ slice .values }})) } {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/excl_min_max.go.tpl b/codegen/templates/validation/excl_min_max.go.tpl index 67d19a2852..ac8b5ac6d8 100644 --- a/codegen/templates/validation/excl_min_max.go.tpl +++ b/codegen/templates/validation/excl_min_max.go.tpl @@ -1,8 +1,8 @@ {{ if .isPointer }}if {{ .target }} != nil { {{ end -}} if {{ .targetVal }} {{ if .isExclMin }}<={{ else }}>={{ end }} {{ if .isExclMin }}{{ .exclMin }}{{ else }}{{ .exclMax }}{{ end }} { - err = goa.MergeErrors(err, goa.InvalidRangeError({{ printf "%q" .context }}, {{ .targetVal }}, {{ if .isExclMin }}{{ .exclMin }}, true{{ else }}{{ .exclMax }}, false{{ end }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.InvalidRangeError({{ validationPath .context }}, {{ .targetVal }}, {{ if .isExclMin }}{{ .exclMin }}, true{{ else }}{{ .exclMax }}, false{{ end }})) } {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/format.go.tpl b/codegen/templates/validation/format.go.tpl index da2999f03b..c31ca71f91 100644 --- a/codegen/templates/validation/format.go.tpl +++ b/codegen/templates/validation/format.go.tpl @@ -1,6 +1,6 @@ {{ if .isPointer }}if {{ .target }} != nil { {{ end -}} - err = goa.MergeErrors(err, goa.ValidateFormat({{ printf "%q" .context }}, {{ .targetVal}}, {{ constant .format }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.ValidateFormat({{ validationPath .context }}, {{ .targetVal}}, {{ constant .format }})) {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/length.go.tpl b/codegen/templates/validation/length.go.tpl index 69b41dc487..80feb15fc9 100644 --- a/codegen/templates/validation/length.go.tpl +++ b/codegen/templates/validation/length.go.tpl @@ -2,8 +2,8 @@ {{ if and .isPointer .string -}} if {{ .target }} != nil { {{ end -}} -if {{ if .string }}utf8.RuneCountInString({{ $target }}){{ else }}len({{ $target }}){{ end }} {{ if .isMinLength }}<{{ else }}>{{ end }} {{ if .isMinLength }}{{ .minLength }}{{ else }}{{ .maxLength }}{{ end }} { - err = goa.MergeErrors(err, goa.InvalidLengthError({{ printf "%q" .context }}, {{ $target }}, {{ if .string }}utf8.RuneCountInString({{ $target }}){{ else }}len({{ $target }}){{ end }}, {{ if .isMinLength }}{{ .minLength }}, true{{ else }}{{ .maxLength }}, false{{ end }})) +if {{ if .string }}{{ .utf8 }}.RuneCountInString({{ $target }}){{ else }}len({{ $target }}){{ end }} {{ if .isMinLength }}<{{ else }}>{{ end }} {{ if .isMinLength }}{{ .minLength }}{{ else }}{{ .maxLength }}{{ end }} { + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.InvalidLengthError({{ validationPath .context }}, {{ $target }}, {{ if .string }}{{ .utf8 }}.RuneCountInString({{ $target }}){{ else }}len({{ $target }}){{ end }}, {{ if .isMinLength }}{{ .minLength }}, true{{ else }}{{ .maxLength }}, false{{ end }})) }{{- if and .isPointer .string }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/min_max.go.tpl b/codegen/templates/validation/min_max.go.tpl index 44fef2c234..51d938a0af 100644 --- a/codegen/templates/validation/min_max.go.tpl +++ b/codegen/templates/validation/min_max.go.tpl @@ -1,8 +1,8 @@ {{ if .isPointer -}}if {{ .target }} != nil { {{ end -}} if {{ .targetVal }} {{ if .isMin }}<{{ else }}>{{ end }} {{ if .isMin }}{{ .min }}{{ else }}{{ .max }}{{ end }} { - err = goa.MergeErrors(err, goa.InvalidRangeError({{ printf "%q" .context }}, {{ .targetVal }}, {{ if .isMin }}{{ .min }}, true{{ else }}{{ .max }}, false{{ end }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.InvalidRangeError({{ validationPath .context }}, {{ .targetVal }}, {{ if .isMin }}{{ .min }}, true{{ else }}{{ .max }}, false{{ end }})) } {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/pattern.go.tpl b/codegen/templates/validation/pattern.go.tpl index 4841eff60d..c5e0671bf8 100644 --- a/codegen/templates/validation/pattern.go.tpl +++ b/codegen/templates/validation/pattern.go.tpl @@ -1,6 +1,6 @@ {{ if .isPointer }}if {{ .target }} != nil { {{ end -}} - err = goa.MergeErrors(err, goa.ValidatePattern({{ printf "%q" .context }}, {{ .targetVal }}, {{ printf "%q" .pattern }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.ValidatePattern({{ validationPath .context }}, {{ .targetVal }}, {{ printf "%q" .pattern }})) {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/required.go.tpl b/codegen/templates/validation/required.go.tpl index afc2a1054b..4138d7ae61 100644 --- a/codegen/templates/validation/required.go.tpl +++ b/codegen/templates/validation/required.go.tpl @@ -1,9 +1,9 @@ -{{- if and (isUnion .reqAtt) (isAttributeScope .attCtx.Scope) (not (isUnionPointer .attCtx true)) }} +{{- if and (isUnion .reqAtt) (isSumType .attCtx.Scope) (not (isUnionPointer .attCtx true)) }} if {{ $.target }}.{{ .attCtx.Scope.Field $.reqAtt .req true }}.Kind() == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("{{ .req }}", {{ printf "%q" $.context }})) + err = {{ $.goa }}.MergeErrors(err, {{ $.goa }}.MissingFieldError("{{ .req }}", {{ validationPath $.context }})) } {{- else }} if {{ $.target }}.{{ .attCtx.Scope.Field $.reqAtt .req true }} == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("{{ .req }}", {{ printf "%q" $.context }})) + err = {{ $.goa }}.MergeErrors(err, {{ $.goa }}.MissingFieldError("{{ .req }}", {{ validationPath $.context }})) } {{- end }} diff --git a/codegen/templates/validation/union.go.tpl b/codegen/templates/validation/union.go.tpl index 9460731efe..1ad02e44c5 100644 --- a/codegen/templates/validation/union.go.tpl +++ b/codegen/templates/validation/union.go.tpl @@ -1,6 +1,18 @@ -switch v := {{ .target }}.(type) { -{{- range $i, $val := .values }} - case {{ index $.types $i }}: - {{ $val }} +switch v := {{ .Target }}.(type) { +{{- range .Cases }} + case {{ .Type }}: + {{- if $.Protobuf }} + if v == nil { + err = {{ $.Goa }}.MergeErrors(err, {{ $.Goa }}.MissingFieldError({{ printf "%q" .Name }}, {{ validationPath $.Context }})) + break + } + {{- if .PayloadRequiresPresence }} + if v.{{ .Field }} == nil { + err = {{ $.Goa }}.MergeErrors(err, {{ $.Goa }}.MissingFieldError({{ printf "%q" .Name }}, {{ validationPath $.Context }})) + break + } + {{- end }} + {{- end }} + {{ .Validation }} {{ end -}} -} \ No newline at end of file +} diff --git a/codegen/templates/validation/user.go.tpl b/codegen/templates/validation/user.go.tpl index cee4c6c8f6..5bddddcf43 100644 --- a/codegen/templates/validation/user.go.tpl +++ b/codegen/templates/validation/user.go.tpl @@ -1,3 +1,4 @@ -if err2 := Validate{{ .name }}({{ .target }}); err2 != nil { - err = goa.MergeErrors(err, err2) -} \ No newline at end of file +if err2 := {{ .call }}; err2 != nil { + err = {{ .goa }}.MergeErrors(err, err2) +} +{{- "" -}} diff --git a/codegen/testdata/golden/go_transform_source-target-type-use-default_defaults-to-defaults-types.go.golden b/codegen/testdata/golden/go_transform_source-target-type-use-default_defaults-to-defaults-types.go.golden index 0e2e496ccd..50bef0f9da 100644 --- a/codegen/testdata/golden/go_transform_source-target-type-use-default_defaults-to-defaults-types.go.golden +++ b/codegen/testdata/golden/go_transform_source-target-type-use-default_defaults-to-defaults-types.go.golden @@ -17,8 +17,7 @@ func transform() { } } { - var zero json.RawMessage - if target.RawJSON == zero { + if target.RawJSON == nil { target.RawJSON = json.RawMessage{0x66, 0x6f, 0x6f} } } @@ -29,14 +28,12 @@ func transform() { } } { - var zero []byte - if target.Bytes == zero { + if target.Bytes == nil { target.Bytes = []byte{0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72} } } { - var zero any - if target.Any == zero { + if target.Any == nil { target.Any = "something" } } diff --git a/codegen/testdata/golden/go_transform_union_UnionSomeType to UnionSomeType2.go.golden b/codegen/testdata/golden/go_transform_union_UnionSomeType to UnionSomeType2.go.golden index 30b0772b94..1ce022fd9c 100644 --- a/codegen/testdata/golden/go_transform_union_UnionSomeType to UnionSomeType2.go.golden +++ b/codegen/testdata/golden/go_transform_union_UnionSomeType to UnionSomeType2.go.golden @@ -3,7 +3,10 @@ func transform() { switch string(source.Kind()) { case "SomeType": actual, _ := source.AsSomeType() - obj := transformSomeTypeToSomeType(actual) + var obj *SomeType + if actual != nil { + obj = transformSomeTypeToSomeType(actual) + } var u UnionSomeType2 u.SetSomeType((*SomeType)(obj)) target = &u diff --git a/codegen/testdata/golden/go_transform_union_nil_branch.go.golden b/codegen/testdata/golden/go_transform_union_nil_branch.go.golden new file mode 100644 index 0000000000..c84416a8a9 --- /dev/null +++ b/codegen/testdata/golden/go_transform_union_nil_branch.go.golden @@ -0,0 +1,78 @@ +func transform() { + var target *State + switch string(source.Kind()) { + case "details": + actual, _ := source.AsDetails() + var obj *Details + if actual != nil { + obj = transformDetailsToDetails(actual) + } + var u State + u.SetDetails((*Details)(obj)) + target = &u + case "empty": + actual, _ := source.AsEmpty() + var obj *Empty + if actual != nil { + obj = transformEmptyToEmpty(actual) + } + var u State + u.SetEmpty((*Empty)(obj)) + target = &u + case "aliases": + actual, _ := source.AsAliases() + var obj []string + if actual != nil { + obj = make([]string, len(actual)) + for i, val := range actual { + obj[i] = val + } + + } + var u State + u.SetAliases(([]string)(obj)) + target = &u + case "labels": + actual, _ := source.AsLabels() + var obj map[string]string + if actual != nil { + obj = make(map[string]string, len(actual)) + for key, val := range actual { + tk := key + tv := val + obj[tk] = tv + } + + } + var u State + u.SetLabels((map[string]string)(obj)) + target = &u + case "blob": + actual, _ := source.AsBlob() + var obj []byte + if actual != nil { + obj = actual + + } + var u State + u.SetBlob(([]byte)(obj)) + target = &u + case "anything": + actual, _ := source.AsAnything() + var obj any + if actual != nil { + obj = actual + + } + var u State + u.SetAnything((any)(obj)) + target = &u + case "name": + actual, _ := source.AsName() + obj := actual + + var u State + u.SetName((string)(obj)) + target = &u + } +} diff --git a/codegen/testdata/golden/validation_alias-type.go.golden b/codegen/testdata/golden/validation_alias-type.go.golden index 4e19459d5d..7eff466ad9 100644 --- a/codegen/testdata/golden/validation_alias-type.go.golden +++ b/codegen/testdata/golden/validation_alias-type.go.golden @@ -8,13 +8,9 @@ func Validate() (err error) { } if target.Alias != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.alias", string(*target.Alias), "^[A-z].*[a-z]$")) - } - if target.Alias != nil { if utf8.RuneCountInString(string(*target.Alias)) < 1 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.alias", string(*target.Alias), utf8.RuneCountInString(string(*target.Alias)), 1, true)) } - } - if target.Alias != nil { if utf8.RuneCountInString(string(*target.Alias)) > 10 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.alias", string(*target.Alias), utf8.RuneCountInString(string(*target.Alias)), 10, false)) } diff --git a/codegen/testdata/golden/validation_chain-holder-pointer.go.golden b/codegen/testdata/golden/validation_chain-holder-pointer.go.golden index ff87e2cfd9..fef37a7d31 100644 --- a/codegen/testdata/golden/validation_chain-holder-pointer.go.golden +++ b/codegen/testdata/golden/validation_chain-holder-pointer.go.golden @@ -6,11 +6,7 @@ func Validate() (err error) { if !(string(*target.ReqMid) == "ab" || string(*target.ReqMid) == "abc" || string(*target.ReqMid) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.req_mid", string(*target.ReqMid), []any{"ab", "abc", "abcd"})) } - } - if target.ReqMid != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.req_mid", string(*target.ReqMid), "^[a-z]+$")) - } - if target.ReqMid != nil { if utf8.RuneCountInString(string(*target.ReqMid)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.req_mid", string(*target.ReqMid), utf8.RuneCountInString(string(*target.ReqMid)), 2, true)) } @@ -19,11 +15,7 @@ func Validate() (err error) { if !(string(*target.Mid) == "ab" || string(*target.Mid) == "abc" || string(*target.Mid) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.mid", string(*target.Mid), []any{"ab", "abc", "abcd"})) } - } - if target.Mid != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.mid", string(*target.Mid), "^[a-z]+$")) - } - if target.Mid != nil { if utf8.RuneCountInString(string(*target.Mid)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.mid", string(*target.Mid), utf8.RuneCountInString(string(*target.Mid)), 2, true)) } @@ -32,11 +24,7 @@ func Validate() (err error) { if !(string(*target.Pass) == "ab" || string(*target.Pass) == "abc" || string(*target.Pass) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.pass", string(*target.Pass), []any{"ab", "abc", "abcd"})) } - } - if target.Pass != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.pass", string(*target.Pass), "^[a-z]+$")) - } - if target.Pass != nil { if utf8.RuneCountInString(string(*target.Pass)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.pass", string(*target.Pass), utf8.RuneCountInString(string(*target.Pass)), 2, true)) } diff --git a/codegen/testdata/golden/validation_chain-holder-required.go.golden b/codegen/testdata/golden/validation_chain-holder-required.go.golden index b3c3da78cc..690def43b6 100644 --- a/codegen/testdata/golden/validation_chain-holder-required.go.golden +++ b/codegen/testdata/golden/validation_chain-holder-required.go.golden @@ -10,11 +10,7 @@ func Validate() (err error) { if !(string(*target.Mid) == "ab" || string(*target.Mid) == "abc" || string(*target.Mid) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.mid", string(*target.Mid), []any{"ab", "abc", "abcd"})) } - } - if target.Mid != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.mid", string(*target.Mid), "^[a-z]+$")) - } - if target.Mid != nil { if utf8.RuneCountInString(string(*target.Mid)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.mid", string(*target.Mid), utf8.RuneCountInString(string(*target.Mid)), 2, true)) } @@ -23,11 +19,7 @@ func Validate() (err error) { if !(string(*target.Pass) == "ab" || string(*target.Pass) == "abc" || string(*target.Pass) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.pass", string(*target.Pass), []any{"ab", "abc", "abcd"})) } - } - if target.Pass != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.pass", string(*target.Pass), "^[a-z]+$")) - } - if target.Pass != nil { if utf8.RuneCountInString(string(*target.Pass)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.pass", string(*target.Pass), utf8.RuneCountInString(string(*target.Pass)), 2, true)) } diff --git a/codegen/testdata/golden/validation_float-pointer.go.golden b/codegen/testdata/golden/validation_float-pointer.go.golden index 51de19e361..f69e7157ec 100644 --- a/codegen/testdata/golden/validation_float-pointer.go.golden +++ b/codegen/testdata/golden/validation_float-pointer.go.golden @@ -21,10 +21,8 @@ func Validate() (err error) { if *target.ExclusiveFloat64 <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) } - } - if target.ExclusiveFloat64 != nil { - if *target.ExclusiveFloat64 <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) + if *target.ExclusiveFloat64 >= 100.1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 100.1, false)) } } } diff --git a/codegen/testdata/golden/validation_float-required.go.golden b/codegen/testdata/golden/validation_float-required.go.golden index 11a198a4eb..e74c9ac4bd 100644 --- a/codegen/testdata/golden/validation_float-required.go.golden +++ b/codegen/testdata/golden/validation_float-required.go.golden @@ -16,10 +16,8 @@ func Validate() (err error) { if *target.ExclusiveFloat64 <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) } - } - if target.ExclusiveFloat64 != nil { - if *target.ExclusiveFloat64 <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) + if *target.ExclusiveFloat64 >= 100.1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 100.1, false)) } } } diff --git a/codegen/testdata/golden/validation_float-use-default.go.golden b/codegen/testdata/golden/validation_float-use-default.go.golden index 92efc1c091..f14511e34f 100644 --- a/codegen/testdata/golden/validation_float-use-default.go.golden +++ b/codegen/testdata/golden/validation_float-use-default.go.golden @@ -14,10 +14,8 @@ func Validate() (err error) { if *target.ExclusiveFloat64 <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) } - } - if target.ExclusiveFloat64 != nil { - if *target.ExclusiveFloat64 <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) + if *target.ExclusiveFloat64 >= 100.1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 100.1, false)) } } } diff --git a/codegen/testdata/golden/validation_integer-pointer.go.golden b/codegen/testdata/golden/validation_integer-pointer.go.golden index 2386735ad4..28c328f6f7 100644 --- a/codegen/testdata/golden/validation_integer-pointer.go.golden +++ b/codegen/testdata/golden/validation_integer-pointer.go.golden @@ -21,10 +21,8 @@ func Validate() (err error) { if *target.ExclusiveInteger <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) } - } - if target.ExclusiveInteger != nil { - if *target.ExclusiveInteger <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) + if *target.ExclusiveInteger >= 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 100, false)) } } } diff --git a/codegen/testdata/golden/validation_integer-required.go.golden b/codegen/testdata/golden/validation_integer-required.go.golden index 84a979e80b..160130d2de 100644 --- a/codegen/testdata/golden/validation_integer-required.go.golden +++ b/codegen/testdata/golden/validation_integer-required.go.golden @@ -16,10 +16,8 @@ func Validate() (err error) { if *target.ExclusiveInteger <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) } - } - if target.ExclusiveInteger != nil { - if *target.ExclusiveInteger <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) + if *target.ExclusiveInteger >= 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 100, false)) } } } diff --git a/codegen/testdata/golden/validation_integer-use-default.go.golden b/codegen/testdata/golden/validation_integer-use-default.go.golden index 9bc2be4599..34fd59d095 100644 --- a/codegen/testdata/golden/validation_integer-use-default.go.golden +++ b/codegen/testdata/golden/validation_integer-use-default.go.golden @@ -14,10 +14,8 @@ func Validate() (err error) { if *target.ExclusiveInteger <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) } - } - if target.ExclusiveInteger != nil { - if *target.ExclusiveInteger <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) + if *target.ExclusiveInteger >= 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 100, false)) } } } diff --git a/codegen/testdata/golden/validation_string-pointer.go.golden b/codegen/testdata/golden/validation_string-pointer.go.golden index 51b19890fd..8127794309 100644 --- a/codegen/testdata/golden/validation_string-pointer.go.golden +++ b/codegen/testdata/golden/validation_string-pointer.go.golden @@ -4,13 +4,9 @@ func Validate() (err error) { } if target.RequiredString != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.required_string", *target.RequiredString, "^[A-z].*[a-z]$")) - } - if target.RequiredString != nil { if utf8.RuneCountInString(*target.RequiredString) < 1 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.required_string", *target.RequiredString, utf8.RuneCountInString(*target.RequiredString), 1, true)) } - } - if target.RequiredString != nil { if utf8.RuneCountInString(*target.RequiredString) > 10 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.required_string", *target.RequiredString, utf8.RuneCountInString(*target.RequiredString), 10, false)) } diff --git a/codegen/testing.go b/codegen/testing.go index 67c1b8ee8b..b35632728b 100644 --- a/codegen/testing.go +++ b/codegen/testing.go @@ -1,3 +1,5 @@ +// This file evaluates isolated Goa designs and renders sections for codegen +// tests without performing generation-owned normalization ahead of the test. package codegen import ( @@ -24,9 +26,6 @@ func RunDSL(t *testing.T, dsl func()) *expr.RootExpr { expr.Root.API.Servers = []*expr.ServerExpr{expr.Root.API.DefaultServer()} require.True(t, eval.Execute(dsl, nil), eval.Context.Error()) require.NoError(t, eval.RunDSL()) - // Apply the sanctioned post-finalization rewrite the production Generate - // flow runs before the generators read the design. - NormalizeRoot(expr.Root) return expr.Root } diff --git a/codegen/transformer.go b/codegen/transformer.go index 35ef51ec2f..454273fa72 100644 --- a/codegen/transformer.go +++ b/codegen/transformer.go @@ -1,3 +1,5 @@ +// This file defines the naming and pointer contracts shared by Go +// transformation and validation generators. package codegen import ( @@ -20,6 +22,17 @@ type ( // attribute and field name. If firstUpper is true then the field name // first letter is capitalized. Field(att *expr.AttributeExpr, name string, firstUpper bool) string + // Package returns the qualifier used to reference att from the current + // generated file, or the empty string for a same-package declaration. + Package(att *expr.AttributeExpr) string + // Enter returns the resolver for the package containing att and declarations + // nested in it. + Enter(att *expr.AttributeExpr) Attributor + // IsSumType reports whether unions use Goa's generated sum-type layout. + IsSumType() bool + // ValidatorCall returns the complete call that validates target as att. + // path is the generated expression used as the start of nested error paths. + ValidatorCall(att *expr.AttributeExpr, view, target, path string) string } // AttributeContext contains properties which impacts the code generating @@ -40,17 +53,14 @@ type ( UseDefault bool // Scope is the attribute scope. Scope Attributor - // DefaultPkg is the default package name where the attribute - // type is found. it can be overridden via struct:pkg:path meta. - DefaultPkg string - // SamePackageConversion if true indicates that this context is being used - // for conversion code generation within the same package as the types. - SamePackageConversion bool // UnionPointer if true indicates that optional sum-type union fields use // pointers to preserve transport-level presence. Required union fields also // use pointers when Pointer is true. Service types leave this false because // the empty union discriminator represents omission after decoding. UnionPointer bool + // ArrayElementPointer keeps primitive array elements as pointers when + // generated validation must distinguish null from the primitive zero value. + ArrayElementPointer bool } // AttributeScope contains the scope of an attribute. It implements the @@ -58,6 +68,8 @@ type ( AttributeScope struct { // scope is the name scope for the attribute. scope *NameScope + // pkg is the default generated Go package qualifier. + pkg string } // TransformAttrs are the attributes that help in the transformation. @@ -69,7 +81,40 @@ type ( // Hooks are optional generator specific extension points // consulted by the transform engine. Nil selects the engine // defaults. - Hooks *TransformHooks + Hooks *TransformHooks + helpers map[TransformHelperID]TransformHelper + calls *transformCallCursor + collectionDepth int + unionDepth int + } + + // TransformHelperID selects one recursive function in a TransformPlan. Its + // fields are private so callers cannot rebuild it from a generated name. + TransformHelperID struct { + plan *TransformPlan + index int + } + + // TransformHelper describes one generated function that converts a nested or + // recursive value. The same chosen function name is used at every call and at + // its definition. + TransformHelper struct { + // ID selects this function in its TransformPlan. + ID TransformHelperID + // Source describes the source attribute selected for this function. + // Helpers returns a detached copy, so changing it does not affect Render. + Source *expr.AttributeExpr + // Target describes the target attribute selected for this function. + // Helpers returns a detached copy, so changing it does not affect Render. + Target *expr.AttributeExpr + // Required reports whether nil is rejected by the helper operation. + Required bool + // Occurrence is the one-based position of this helper operation in the + // transform plan's stable traversal. + Occurrence int + // Declaration holds the package-level function name chosen before source + // is written. Render returns an error when it is missing. + Declaration *NameDeclaration } // TransformFunctionData describes a helper function used to transform @@ -90,10 +135,84 @@ type ( // } // TransformFunctionData struct { - Name string - ParamTypeRef string + // ID selects the recursive function rendered by this value. + ID TransformHelperID + // Declaration is the package-level function chosen before writing code. + // It is nil when GoTransformWithAttrs created this value while writing + // code. + Declaration *NameDeclaration + // Name is the final helper name kept for existing plugins. + // + // Deprecated: Use Declaration.Name() after planning. + Name string + // ParamTypeRef is the generated Go reference to the helper parameter type. + ParamTypeRef string + // ResultTypeRef is the generated Go reference to the helper result type. ResultTypeRef string - Code string + // Code is the helper body. + Code string + } + + // TransformPlan owns copied source and target expressions plus every + // recursive function needed to convert between them. Create a plan, inspect + // the detached helper descriptions from Helpers and declare their names, bind + // those declarations and the completed type resolvers, then call Render. + // A helper ID remains bound to this plan, but changing a description returned + // by Helpers cannot change the private expressions Render uses. Render caches + // each exact argument set, so repeated calls return the first generated code. + TransformPlan struct { + source *expr.AttributeExpr + target *expr.AttributeExpr + sourceBaseline *expr.AttributeExpr + targetBaseline *expr.AttributeExpr + sourceOriginals map[*expr.AttributeExpr]*expr.AttributeExpr + targetOriginals map[*expr.AttributeExpr]*expr.AttributeExpr + prefix string + hooks *TransformHooks + sourceCtx *AttributeContext + targetCtx *AttributeContext + helpers []TransformHelper + operations []*transformOperation + renders map[transformRenderRequest]transformRenderResult + } + + // transformRenderRequest identifies one Render invocation. Repeating the + // same invocation returns its first result instead of invoking hooks again. + transformRenderRequest struct { + sourceVar string + targetVar string + newVar bool + } + + // transformRenderResult is the private immutable cache for one Render call. + transformRenderResult struct { + code string + helpers []*TransformFunctionData + err error + } + + // transformPair holds the exact copied source and target types whose fields + // are currently being visited. + transformPair struct { + source expr.DataType + target expr.DataType + } + + // transformOperation stores the recursive calls made by the top-level + // conversion or one function body, in call order. + transformOperation struct { + calls []transformCall + } + + // transformCall selects the recursive function used by one call. + transformCall struct { + helper TransformHelperID + } + + // transformCallCursor counts the planned calls used by one render. + transformCallCursor struct { + calls []transformCall + next int } ) @@ -103,21 +222,21 @@ func NewAttributeContext(pointer, reqIgnore, useDefault bool, pkg string, scope Pointer: pointer, IgnoreRequired: reqIgnore, UseDefault: useDefault, - Scope: NewAttributeScope(scope), - DefaultPkg: pkg, + Scope: newAttributeScope(scope, pkg), } } -// NewAttributeContextForConversion initializes an attribute context for same-package conversion. -func NewAttributeContextForConversion(pointer, reqIgnore, useDefault bool, pkg string, scope *NameScope) *AttributeContext { - ctx := NewAttributeContext(pointer, reqIgnore, useDefault, pkg, scope) - ctx.SamePackageConversion = true - return ctx -} - // NewAttributeScope initializes an attribute scope. func NewAttributeScope(scope *NameScope) *AttributeScope { - return &AttributeScope{scope: scope} + return newAttributeScope(scope, "") +} + +// EnterCollection returns the loop variable for the current array and a copy +// used to render values nested inside that array. +func (a *TransformAttrs) EnterCollection() (string, *TransformAttrs) { + child := *a + child.collectionDepth++ + return string(rune('i' + a.collectionDepth)), &child } // IsCompatible returns an error if a and b are not both objects, both arrays, @@ -158,13 +277,19 @@ func IsCompatible(a, b expr.DataType, actx, bctx string) error { return nil } -// AppendHelpers takes care of only appending helper functions from newH that -// are not already in oldH. +// AppendHelpers appends functions from newH that oldH does not already contain. +// Planned functions are the same when they use the same package declaration. +// Older functions without a declaration are the same when their names match. +// It panics when one declaration or released name has different parameter, +// result, or body text because one Go function cannot implement both values. func AppendHelpers(oldH, newH []*TransformFunctionData) []*TransformFunctionData { for _, h := range newH { found := false for _, h2 := range oldH { - if h.Name == h2.Name { + if sameTransformHelper(h, h2) { + if !transformFunctionDefinitionsEqual(h, h2) { + panic(fmt.Sprintf("transform helper %q has different definitions", h.Name)) + } found = true break } @@ -176,28 +301,41 @@ func AppendHelpers(oldH, newH []*TransformFunctionData) []*TransformFunctionData return oldH } +// sameTransformHelper compares the chosen package declarations when both +// helpers have one. Values created while writing code have no declaration and +// keep using their generated names. +func sameTransformHelper(left, right *TransformFunctionData) bool { + if left.Declaration != nil && right.Declaration != nil { + return left.Declaration == right.Declaration + } + if left.Declaration != nil || right.Declaration != nil { + return false + } + return left.Name == right.Name +} + // MapDepth returns the level of nested maps. For unnested maps, it returns 0. func MapDepth(m *expr.Map) int { return mapDepth(m.ElemType.Type, 0) } -func mapDepth(dt expr.DataType, depth int, seen ...map[string]struct{}) int { +func mapDepth(dt expr.DataType, depth int, seen ...map[expr.DataType]struct{}) int { if mp := expr.AsMap(dt); mp != nil { depth++ depth = mapDepth(mp.ElemType.Type, depth, seen...) } else if ar := expr.AsArray(dt); ar != nil { depth = mapDepth(ar.ElemType.Type, depth, seen...) } else if mo := expr.AsObject(dt); mo != nil { - var s map[string]struct{} + var s map[expr.DataType]struct{} if len(seen) > 0 { s = seen[0] } else { - s = make(map[string]struct{}) + s = make(map[expr.DataType]struct{}) seen = append(seen, s) } - key := dt.Name() + key := dt if u, ok := dt.(expr.UserType); ok { - key = u.ID() + key = u.Origin() } if _, ok := s[key]; ok { return depth @@ -237,7 +375,7 @@ func (a *AttributeContext) IsFieldPointer(name string, att *expr.AttributeExpr) if expr.IsUnion(field.Type) { return a.IsUnionPointer(att.IsRequired(name)) } - if _, ok := a.Scope.(*AttributeScope); !ok { + if !a.Scope.IsSumType() { return expr.IsPrimitive(field.Type) && a.IsPrimitivePointer(name, att) } return goFieldIsPointer(att, name, a.Pointer, a.UseDefault) @@ -249,39 +387,34 @@ func (a *AttributeContext) IsUnionPointer(required bool) bool { return a.UnionPointer && (!required || a.Pointer) } +// IsArrayElementPointer reports whether primitive elements in array use +// pointers so generated validation can reject null before conversion. +func (a *AttributeContext) IsArrayElementPointer(array *expr.Array) bool { + return arrayElementIsPointer(array, a.ArrayElementPointer) +} + // Pkg returns the package name of the given type. func (a *AttributeContext) Pkg(att *expr.AttributeExpr) string { - if att == nil { - return a.DefaultPkg - } - if loc := UserTypeLocation(att.Type); loc != nil { - pkg := loc.PackageName() - // If this is same-package conversion and the type's package matches - // the context's default package, return empty string to avoid qualification - if a.SamePackageConversion && pkg == a.DefaultPkg { - return "" - } - return pkg - } - if expr.AsUnion(att.Type) != nil { - if a.SamePackageConversion { - return "" - } - return a.DefaultPkg - } - return a.DefaultPkg + return a.Scope.Package(att) +} + +// Enter returns a copy whose attributor owns att and unlocated declarations +// nested inside it. +func (a *AttributeContext) Enter(att *expr.AttributeExpr) *AttributeContext { + entered := a.Dup() + entered.Scope = a.Scope.Enter(att) + return entered } // Dup creates a shallow copy of the AttributeContext. func (a *AttributeContext) Dup() *AttributeContext { return &AttributeContext{ - Pointer: a.Pointer, - IgnoreRequired: a.IgnoreRequired, - UseDefault: a.UseDefault, - Scope: a.Scope, - DefaultPkg: a.DefaultPkg, - SamePackageConversion: a.SamePackageConversion, - UnionPointer: a.UnionPointer, + Pointer: a.Pointer, + IgnoreRequired: a.IgnoreRequired, + UseDefault: a.UseDefault, + Scope: a.Scope, + UnionPointer: a.UnionPointer, + ArrayElementPointer: a.ArrayElementPointer, } } @@ -314,6 +447,40 @@ func (a *AttributeScope) Ref(att *expr.AttributeExpr, pkg string) string { return a.scope.GoFullTypeRef(att, pkg) } +// Package returns the qualifier selected by att's explicit type location or +// the scope's default package. +func (a *AttributeScope) Package(att *expr.AttributeExpr) string { + if att == nil { + return a.pkg + } + if loc := UserTypeLocation(att.Type); loc != nil { + return loc.PackageName() + } + return a.pkg +} + +// ValidatorCall returns a call to the validation function selected from the +// generated type and view names. +func (a *AttributeScope) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + name := "Validate" + a.Name(att, "", false, true) + Goify(view, true) + return fmt.Sprintf("%s(%s)", name, target) +} + +// Enter returns a scope whose default qualifier follows att's explicit type +// location. The underlying name scope remains unchanged. +func (a *AttributeScope) Enter(att *expr.AttributeExpr) Attributor { + if loc := UserTypeLocation(att.Type); loc != nil && loc.PackageName() != a.pkg { + return newAttributeScope(a.scope, loc.PackageName()) + } + return a +} + +// IsSumType reports that AttributeScope renders unions using Goa's generated +// sum-type structs. +func (*AttributeScope) IsSumType() bool { + return true +} + // Field returns a valid Go struct field name. func (*AttributeScope) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { return GoifyAtt(att, name, firstUpper) @@ -323,3 +490,9 @@ func (*AttributeScope) Field(att *expr.AttributeExpr, name string, firstUpper bo func (a *AttributeScope) Scope() *NameScope { return a.scope } + +// newAttributeScope builds an attribute scope with explicit package +// qualification behavior. +func newAttributeScope(scope *NameScope, pkg string) *AttributeScope { + return &AttributeScope{scope: scope, pkg: pkg} +} diff --git a/codegen/transformer_test.go b/codegen/transformer_test.go index 1a40acf303..7d8f24122b 100644 --- a/codegen/transformer_test.go +++ b/codegen/transformer_test.go @@ -1,11 +1,81 @@ +// This file verifies shared transform rules that are independent of a +// transport generator. package codegen import ( "testing" + "github.com/stretchr/testify/assert" + "goa.design/goa/v3/expr" ) +func TestAppendHelpersUsesDeclarationIdentity(t *testing.T) { + firstPlan := &TransformPlan{} + secondPlan := &TransformPlan{} + shared := NewExactName(NameFunction, "sharedHelper") + separate := NewExactName(NameFunction, "separateHelper") + old := []*TransformFunctionData{ + {ID: TransformHelperID{plan: firstPlan}, Declaration: shared, Name: "sharedHelper"}, + {ID: TransformHelperID{plan: firstPlan, index: 1}, Declaration: separate, Name: "separateHelper"}, + {Name: "legacyOne"}, + } + added := []*TransformFunctionData{ + {ID: TransformHelperID{plan: secondPlan}, Declaration: shared, Name: "sharedHelper"}, + {Name: "sharedHelper"}, + {Name: "legacyOne"}, + {Name: "legacyTwo"}, + } + + got := AppendHelpers(old, added) + + if assert.Len(t, got, 5) { + assert.Same(t, shared, got[0].Declaration) + assert.Same(t, separate, got[1].Declaration) + assert.Nil(t, got[2].Declaration) + assert.Nil(t, got[3].Declaration) + assert.Nil(t, got[4].Declaration) + assert.Equal(t, "sharedHelper", got[3].Name) + assert.Equal(t, "legacyTwo", got[4].Name) + } +} + +func TestAppendHelpersRejectsConflictingLegacyDefinitions(t *testing.T) { + tests := map[string]*TransformFunctionData{ + "parameter type": { + Name: "transformValue", + ParamTypeRef: "OtherSource", + ResultTypeRef: "Target", + Code: "return value", + }, + "result type": { + Name: "transformValue", + ParamTypeRef: "Source", + ResultTypeRef: "OtherTarget", + Code: "return value", + }, + "body": { + Name: "transformValue", + ParamTypeRef: "Source", + ResultTypeRef: "Target", + Code: "return other", + }, + } + for name, added := range tests { + t.Run(name, func(t *testing.T) { + old := []*TransformFunctionData{{ + Name: "transformValue", + ParamTypeRef: "Source", + ResultTypeRef: "Target", + Code: "return value", + }} + assert.PanicsWithValue(t, "transform helper \"transformValue\" has different definitions", func() { + AppendHelpers(old, []*TransformFunctionData{added}) + }) + }) + } +} + func TestIsPrimitivePointer(t *testing.T) { newObj := func(fieldName string, fieldType expr.DataType, req bool) *expr.AttributeExpr { attr := &expr.AttributeExpr{ diff --git a/codegen/types.go b/codegen/types.go index 3e2e8dbb79..38f7bdd904 100644 --- a/codegen/types.go +++ b/codegen/types.go @@ -43,11 +43,19 @@ func GoNativeTypeName(t expr.DataType) string { // IsNilable reports whether the Go type generated for t can be nil. func IsNilable(t expr.DataType) bool { + underlying := unalias(t) return expr.IsObject(t) || expr.IsArray(t) || expr.IsMap(t) || - t.Kind() == expr.BytesKind || - t.Kind() == expr.AnyKind + underlying.Kind() == expr.BytesKind || + underlying.Kind() == expr.AnyKind +} + +// arrayElementIsPointer reports whether validation needs a pointer to tell a +// null element apart from the element type's zero value. +func arrayElementIsPointer(array *expr.Array, enabled bool) bool { + return enabled && array.NonNullableElems && expr.IsPrimitive(array.ElemType.Type) && + !IsNilable(array.ElemType.Type) } // goFieldIsPointer reports whether a field in a generated Goa service struct diff --git a/codegen/union.go b/codegen/union.go new file mode 100644 index 0000000000..e9800c5b73 --- /dev/null +++ b/codegen/union.go @@ -0,0 +1,132 @@ +// This file builds a repeatable key from every detail that changes a generated +// union's Go or JSON definition. +package codegen + +import ( + "strconv" + "strings" + + "goa.design/goa/v3/expr" +) + +type ( + // UnionTypeID identifies the Go and JSON definition emitted for a union. + // It is distinct from expr.Union.Hash, which describes design compatibility. + UnionTypeID string +) + +// NewUnionTypeID returns a repeatable key for union's generated Go and JSON +// definitions. The key includes the effective JSON envelope keys and every +// detail that changes a generated Go branch type: package location, field type +// metadata, and whether the value may be nil. +func NewUnionTypeID(union *expr.Union) UnionTypeID { + var key strings.Builder + writeUnionTypeID( + &key, + union, + make(map[*expr.Object]int), + make(map[*expr.Union]int), + make(map[expr.UserType]int), + ) + return UnionTypeID(key.String()) +} + +// Hash returns the repeatable key used to look up this union's Go name in a +// generated package. +func (id UnionTypeID) Hash() string { + return string(id) +} + +// writeUnionTypeID appends one union definition using length-prefixed values +// so different inputs cannot produce an ambiguous concatenation. +func writeUnionTypeID(key *strings.Builder, union *expr.Union, objects map[*expr.Object]int, unions map[*expr.Union]int, userTypes map[expr.UserType]int) { + if index, ok := unions[union]; ok { + writeUnionIDPart(key, "union-ref") + writeUnionIDPart(key, strconv.Itoa(index)) + return + } + unions[union] = len(unions) + defer delete(unions, union) + writeUnionIDPart(key, "union") + writeUnionIDPart(key, union.TypeName) + writeUnionIDPart(key, union.GetTypeKey()) + writeUnionIDPart(key, union.GetValueKey()) + for _, value := range union.Values { + writeUnionIDPart(key, value.Name) + writeUnionAttributeID(key, value.Attribute, objects, unions, userTypes) + } +} + +// writeUnionAttributeID appends every attribute detail that changes generated +// Go code. +func writeUnionAttributeID(key *strings.Builder, att *expr.AttributeExpr, objects map[*expr.Object]int, unions map[*expr.Union]int, userTypes map[expr.UserType]int) { + writeUnionIDPart(key, strconv.FormatBool(IsNilable(att.Type))) + if metaType, ok := att.Meta["struct:field:type"]; ok { + writeUnionIDPart(key, "meta-type") + for _, value := range metaType { + writeUnionIDPart(key, value) + } + } + switch actual := att.Type.(type) { + case expr.Primitive: + writeUnionIDPart(key, "primitive") + writeUnionIDPart(key, GoNativeTypeName(actual)) + case expr.UserType: + writeUnionIDPart(key, "user") + writeUnionIDPart(key, Goify(actual.Name(), true)) + if loc := UserTypeLocation(actual); loc != nil { + writeUnionIDPart(key, loc.RelImportPath) + } else { + writeUnionIDPart(key, "") + } + origin := actual.Origin() + if index, ok := userTypes[origin]; ok { + writeUnionIDPart(key, "user-ref") + writeUnionIDPart(key, strconv.Itoa(index)) + return + } + userTypes[origin] = len(userTypes) + defer delete(userTypes, origin) + writeUnionAttributeID(key, actual.Attribute(), objects, unions, userTypes) + case *expr.Array: + writeUnionIDPart(key, "array") + writeUnionAttributeID(key, actual.ElemType, objects, unions, userTypes) + case *expr.Map: + writeUnionIDPart(key, "map") + writeUnionAttributeID(key, actual.KeyType, objects, unions, userTypes) + writeUnionAttributeID(key, actual.ElemType, objects, unions, userTypes) + case *expr.Object: + writeUnionObjectID(key, att, actual, objects, unions, userTypes) + case *expr.Union: + writeUnionTypeID(key, actual, objects, unions, userTypes) + case expr.CompositeExpr: + writeUnionAttributeID(key, actual.Attribute(), objects, unions, userTypes) + default: + panic("unknown union branch data type") + } +} + +// writeUnionObjectID appends the inline Go struct emitted for an object. +func writeUnionObjectID(key *strings.Builder, parent *expr.AttributeExpr, object *expr.Object, objects map[*expr.Object]int, unions map[*expr.Union]int, userTypes map[expr.UserType]int) { + if index, ok := objects[object]; ok { + writeUnionIDPart(key, "object-ref") + writeUnionIDPart(key, strconv.Itoa(index)) + return + } + objects[object] = len(objects) + defer delete(objects, object) + writeUnionIDPart(key, "object") + for _, field := range *object { + writeUnionIDPart(key, GoifyAtt(field.Attribute, field.Name, true)) + writeUnionIDPart(key, AttributeTagsWithName(parent, field.Name, field.Attribute)) + writeUnionIDPart(key, strconv.FormatBool(goFieldIsPointer(parent, field.Name, false, false))) + writeUnionAttributeID(key, field.Attribute, objects, unions, userTypes) + } +} + +// writeUnionIDPart appends one unambiguous string component to key. +func writeUnionIDPart(key *strings.Builder, value string) { + key.WriteString(strconv.Itoa(len(value))) + key.WriteByte(':') + key.WriteString(value) +} diff --git a/codegen/validation.go b/codegen/validation.go index 384bdf15fd..1915925ab7 100644 --- a/codegen/validation.go +++ b/codegen/validation.go @@ -1,15 +1,60 @@ +// This file generates functions that check service values and values sent over +// HTTP, gRPC, and JSON-RPC. Each function uses the Go names already chosen for +// its package. package codegen import ( "bytes" - "errors" "fmt" + "strconv" "strings" "text/template" "goa.design/goa/v3/expr" ) +type ( + // unionValidationCase describes one possible union branch in generated + // validation code. + unionValidationCase struct { + // Type is the generated Go type for the branch. + Type string + // Field is the field which stores the branch value. + Field string + // Name is the branch name shown in validation errors. + Name string + // PayloadRequiresPresence is true when selecting this branch also + // requires a non-nil value. + PayloadRequiresPresence bool + // Validation checks the value stored by this branch. + Validation string + } + + // unionValidationData contains the information needed to write one union + // check. + unionValidationData struct { + // Target is the generated union value being checked. + Target string + // Context identifies the union in validation errors. + Context validationPath + // Protobuf is true when each selected branch is stored in its own generated + // protobuf struct. + Protobuf bool + // Cases lists every branch accepted by the union. + Cases []unionValidationCase + // Goa is the generated import name of Goa's error package. + Goa string + } + + // validationPath stores an error path while Goa writes validation source. + // variable is true when root names a parameter in the generated function. + validationPath struct { + root string + suffix string + variable bool + } +) + var ( enumValT *template.Template formatValT *template.Template @@ -27,21 +72,21 @@ var ( func init() { fm := template.FuncMap{ - "slice": toSlice, - "oneof": oneof, - "constant": constant, + "slice": toSlice, + "oneof": oneof, + "constant": constant, + "validationPath": renderValidationPath, "isUnion": func(att *expr.AttributeExpr) bool { if att == nil { return false } return expr.IsUnion(att.Type) }, - "isAttributeScope": func(scope Attributor) bool { + "isSumType": func(scope Attributor) bool { if scope == nil { return false } - _, ok := scope.(*AttributeScope) - return ok + return scope.IsSumType() }, "isUnionPointer": func(ctx *AttributeContext, required bool) bool { return ctx.IsUnionPointer(required) @@ -67,7 +112,7 @@ func init() { // // See ValidationCode for a description of the arguments. func AttributeValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias bool, target, attName string) string { - return recurseValidationCode(att, put, attCtx, req, alias, false, target, attName, nil).String() + return recurseValidationCode(att, put, attCtx, req, alias, false, target, literalValidationPath(attName), nil).String() } // ValidationCode produces Go code that runs the validations defined in the @@ -91,12 +136,26 @@ func AttributeValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx // // context is used to produce helpful messages in case of error. func ValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target string) string { - return recurseValidationCode(att, put, attCtx, req, alias, view, target, target, nil).String() + return recurseValidationCode(att, put, attCtx, req, alias, view, target, literalValidationPath(target), nil).String() +} + +// ValidationCodeWithPathParameter produces validation code whose error paths +// begin with the string held by pathParameter. target and pathParameter are Go +// expressions. +func ValidationCodeWithPathParameter(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target, pathParameter string) string { + return recurseValidationCode(att, put, attCtx, req, alias, view, target, parameterValidationPath(pathParameter), nil).String() +} + +func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target string, context validationPath, seen map[expr.UserType]*bytes.Buffer) *bytes.Buffer { + return renderValidationCode(att, put, attCtx, req, alias, view, target, context, seen, true) } -func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target, context string, seen map[string]*bytes.Buffer) *bytes.Buffer { +// renderValidationCode writes one validation tree. localGuards reports whether +// local rule templates must check a pointer before reading it. Nested fields +// disable those checks when validateAttribute wraps the whole field once. +func renderValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target string, context validationPath, seen map[expr.UserType]*bytes.Buffer, localGuards bool) *bytes.Buffer { if seen == nil { - seen = make(map[string]*bytes.Buffer) + seen = make(map[expr.UserType]*bytes.Buffer) } var ( buf = new(bytes.Buffer) @@ -109,10 +168,11 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A // so alias types shouldn't use the recursion guard. Only non-alias user // types need cycle protection. if isUT && !alias { - if buf, ok := seen[ut.ID()]; ok { + origin := ut.Origin() + if buf, ok := seen[origin]; ok { return buf } - seen[ut.ID()] = buf + seen[origin] = buf } newline := func() { @@ -124,7 +184,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } // Write validations on attribute if any. - validation := validationCode(att, attCtx, req, alias, target, context) + validation := validationCode(att, attCtx, req, alias, target, context, localGuards) if validation != "" { buf.WriteString(validation) first = false @@ -138,7 +198,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } for _, nat := range *(expr.AsObject(att.Type)) { tgt := fmt.Sprintf("%s.%s", target, attCtx.Scope.Field(nat.Attribute, nat.Name, true)) - ctx := fmt.Sprintf("%s.%s", context, nat.Name) + ctx := context.child("." + nat.Name) val := validateAttribute(attCtx, nat.Attribute, put, tgt, ctx, att.IsRequired(nat.Name), view, seen) if val != "" { newline() @@ -149,19 +209,21 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A arr := expr.AsArray(att.Type) elem := arr.ElemType ctx := attCtx - if ctx.Pointer && expr.IsPrimitive(elem.Type) { - // Array elements of primitive type are never pointers + if expr.IsPrimitive(elem.Type) { ctx = attCtx.Dup() - ctx.Pointer = false + ctx.Pointer = attCtx.IsArrayElementPointer(arr) } - val := validateAttribute(ctx, elem, put, "e", context+"[*]", true, view, seen) - if val != "" || arr.NonNullableElems { + val := validateAttribute(ctx, elem, put, "e", context.child("[*]"), true, view, seen) + nonNullableElems := arr.NonNullableElems && + (IsNilable(elem.Type) || attCtx.IsArrayElementPointer(arr)) + if val != "" || nonNullableElems { newline() data := map[string]any{ "target": target, "validation": val, - "nonNullableElems": arr.NonNullableElems, + "checkNilElements": nonNullableElems, "context": context, + "goa": "goa", } if err := arrayValT.Execute(buf, data); err != nil { panic(err) // bug @@ -171,11 +233,11 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A m := expr.AsMap(att.Type) ctx := attCtx.Dup() ctx.Pointer = false - keyVal := validateAttribute(ctx, m.KeyType, put, "k", context+".key", true, view, seen) + keyVal := validateAttribute(ctx, m.KeyType, put, "k", context.child(".key"), true, view, seen) if keyVal != "" { keyVal = "\n" + keyVal } - valueVal := validateAttribute(ctx, m.ElemType, put, "v", context+"[key]", true, view, seen) + valueVal := validateAttribute(ctx, m.ElemType, put, "v", context.child("[key]"), true, view, seen) if valueVal != "" { valueVal = "\n" + valueVal } @@ -188,7 +250,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } case expr.IsUnion(att.Type): u := expr.AsUnion(att.Type) - if _, ok := attCtx.Scope.(*AttributeScope); ok { + if attCtx.Scope.IsSumType() { cases := make([]map[string]any, 0, len(u.Values)) for _, v := range u.Values { // Sum-type unions (struct-based, with Kind/AsX accessors) store each @@ -198,7 +260,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A // only keep pointer semantics when both layers use pointers. unionCtx := attCtx.Dup() unionCtx.Pointer = unionCtx.Pointer && expr.IsObject(v.Attribute.Type) - val := validateAttribute(unionCtx, v.Attribute, put, "actual", context+".value", true, view, seen) + val := validateAttribute(unionCtx, v.Attribute, put, "actual", context.child(".value"), true, view, seen) if val == "" { continue } @@ -213,6 +275,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A data := map[string]any{ "target": target, "cases": cases, + "goa": "goa", } if err := unionSumValT.Execute(buf, data); err != nil { panic(err) // bug @@ -222,35 +285,42 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } // Validate unions represented as interfaces (e.g., protobuf oneof wrappers). - var vals []string - var types []string + var cases []unionValidationCase for _, v := range u.Values { vatt := v.Attribute if view { // Union values in views are never pointers - they are concrete typed values unionCtx := attCtx.Dup() unionCtx.Pointer = false - val := validateAttribute(unionCtx, vatt, put, "v", context+".value", true, view, seen) + val := validateAttribute(unionCtx, vatt, put, "v", context.child(".value"), true, view, seen) if val != "" { - types = append(types, attCtx.Scope.Ref(vatt, attCtx.DefaultPkg)) - vals = append(vals, val) + cases = append(cases, unionValidationCase{ + Type: attCtx.Scope.Ref(vatt, attCtx.Pkg(vatt)), + Validation: val, + }) } } else { fieldName := attCtx.Scope.Field(vatt, v.Name, true) - val := validateAttribute(attCtx, vatt, put, "v."+fieldName, context+".value", true, view, seen) - if val != "" { - tref := attCtx.Scope.Ref(&expr.AttributeExpr{Type: put}, attCtx.DefaultPkg) - types = append(types, tref+"_"+fieldName) - vals = append(vals, val) - } + val := validateAttribute(attCtx, vatt, put, "v."+fieldName, context.child(".value"), true, view, seen) + parent := &expr.AttributeExpr{Type: put} + tref := attCtx.Scope.Ref(parent, attCtx.Pkg(parent)) + cases = append(cases, unionValidationCase{ + Type: tref + "_" + fieldName, + Field: fieldName, + Name: v.Name, + PayloadRequiresPresence: protobufUnionPayloadRequiresPresence(vatt), + Validation: val, + }) } } - if len(vals) > 0 { + if len(cases) > 0 { newline() - data := map[string]any{ - "target": target, - "types": types, - "values": vals, + data := unionValidationData{ + Target: target, + Context: context, + Protobuf: !view, + Cases: cases, + Goa: "goa", } if err := unionValT.Execute(buf, data); err != nil { panic(err) // bug @@ -261,38 +331,29 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A return buf } -func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr.UserType, target, context string, req, view bool, seen map[string]*bytes.Buffer) string { +// protobufUnionPayloadRequiresPresence reports whether selecting a protobuf +// union branch requires a non-nil value. Messages, byte slices, and Any values +// may be nil in Go, so their generated checks must reject nil explicitly. +func protobufUnionPayloadRequiresPresence(att *expr.AttributeExpr) bool { + kind := unalias(att.Type).Kind() + return !expr.IsPrimitive(att.Type) || kind == expr.BytesKind || kind == expr.AnyKind +} + +func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr.UserType, target string, context validationPath, req, view bool, seen map[expr.UserType]*bytes.Buffer) string { ut, isUT := att.Type.(expr.UserType) if !isUT { - code := recurseValidationCode(att, put, ctx, req, false, view, target, context, seen).String() + guard := validationAttributeNeedsNilGuard(att, ctx, req) + code := renderValidationCode(att, put, ctx, req, false, view, target, context, seen, !guard).String() if code == "" { return "" } if expr.IsArray(att.Type) || expr.IsMap(att.Type) { return code } - if expr.IsUnion(att.Type) { - _, sumType := ctx.Scope.(*AttributeScope) - if sumType { - if !ctx.IsUnionPointer(req) { - return code - } - } else if req { - return code - } - cond := fmt.Sprintf("if %s != nil {\n", target) - if strings.HasPrefix(code, cond) { - return code - } - return fmt.Sprintf("%s%s\n}", cond, code) - } - if !ctx.Pointer && (req || (att.DefaultValue != nil && ctx.UseDefault)) { + if !guard { return code } cond := fmt.Sprintf("if %s != nil {\n", target) - if strings.HasPrefix(code, cond) { - return code - } return fmt.Sprintf("%s%s\n}", cond, code) } // Alias user types: validate underlying attribute with alias flag so that @@ -303,36 +364,44 @@ func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr. // validating alias user types against their underlying base. Passing // the original attribute with alias=true ensures validations operate // on the correct value type without dropping field defaults. - code := recurseValidationCode(att, put, ctx, req, true, view, target, context, seen).String() + guard := validationAttributeNeedsNilGuard(att, ctx, req) + code := renderValidationCode(att, put, ctx, req, true, view, target, context, seen, !guard).String() if code == "" { return "" } - // For optional pointer fields, wrap validation code in nil check - if !ctx.Pointer && (req || (att.DefaultValue != nil && ctx.UseDefault)) { + if !guard { return code } cond := fmt.Sprintf("if %s != nil {\n", target) - if strings.HasPrefix(code, cond) { - return code - } return fmt.Sprintf("%s%s\n}", cond, code) } if !hasValidations(ctx, ut) { return "" } var buf bytes.Buffer - name := ctx.Scope.Name(att, "", ctx.Pointer, ctx.UseDefault) - // Use the scoped type name directly to preserve identifiers such as - // protocol buffer-reserved names that include a trailing underscore - // (e.g., Message_). Applying Goify here would drop underscores and - // cause mismatches between function declarations and call sites. - data := map[string]any{"name": name, "target": target} + call := ctx.Scope.ValidatorCall(att, "", target, renderValidationPath(context)) + data := map[string]any{"call": call, "goa": "goa"} if err := userValT.Execute(&buf, data); err != nil { panic(err) // bug } return fmt.Sprintf("if %s != nil {\n\t%s\n}", target, buf.String()) } +// validationAttributeNeedsNilGuard reports whether a nested value may be nil +// in the generated Go layout and must be checked before any validation uses it. +func validationAttributeNeedsNilGuard(att *expr.AttributeExpr, ctx *AttributeContext, required bool) bool { + if expr.IsArray(att.Type) || expr.IsMap(att.Type) { + return false + } + if expr.IsUnion(att.Type) { + if ctx.Scope.IsSumType() { + return ctx.IsUnionPointer(required) + } + return !required + } + return ctx.Pointer || !required && (att.DefaultValue == nil || !ctx.UseDefault) +} + // validationCode produces Go code that runs the validations that effectively // apply to the given attribute - see expr.EffectiveValidation - if any // against the content of the variable named target. The generated code @@ -353,7 +422,7 @@ func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr. // target is the variable name against which the validation code is generated // // context is used to produce helpful messages in case of error. -func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alias bool, target, context string) string { +func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alias bool, target string, context validationPath, localGuards bool) string { validation := expr.EffectiveValidation(att) if validation == nil { return "" @@ -378,10 +447,12 @@ func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alia data := map[string]any{ "attribute": att, "attCtx": attCtx, - "isPointer": isPointer, + "isPointer": isPointer && localGuards, "context": context, "target": target, "targetVal": tval, + "goa": "goa", + "utf8": "utf8", "string": kind == expr.StringKind, "array": expr.IsArray(att.Type), "map": expr.IsMap(att.Type), @@ -434,7 +505,7 @@ func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alia } if exclMax := validation.ExclusiveMaximum; exclMax != nil { data["exclMax"] = *exclMax - data["isExclMax"] = true + data["isExclMin"] = false if val := runTemplate(exclMinMaxValT, data); val != "" { res = append(res, val) } @@ -473,29 +544,48 @@ func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alia return strings.Join(res, "\n") } -// hasValidations returns true if a UserType contains validations. It is a -// pure predicate: it never mutates the design expression tree. +// literalValidationPath folds a complete error path into a quoted Go string +// while Goa is generating source. +func literalValidationPath(root string) validationPath { + return validationPath{root: root} +} + +// parameterValidationPath writes an error path relative to the string held by +// a generated validator parameter. +func parameterValidationPath(parameter string) validationPath { + return validationPath{root: parameter, variable: true} +} + +// child returns the context used for a field or collection value below c. +func (p validationPath) child(prefix string) validationPath { + p.suffix += prefix + return p +} + +// renderValidationPath returns the Go expression passed to a generated +// validation error. +func renderValidationPath(path validationPath) string { + if !path.variable { + return strconv.Quote(path.root + path.suffix) + } + if path.suffix == "" { + return path.root + } + return path.root + " + " + strconv.Quote(path.suffix) +} + +// hasValidations reports whether validating ut can write any code with the Go +// layout described by attCtx. func hasValidations(attCtx *AttributeContext, ut expr.UserType) bool { - // We need to check empirically whether there are validations to be - // generated. We can't call recurseValidationCode() to avoid infinite - // recursions, but we can use validationCode() for the local (non-recursive) - // attribute-level checks — it is the source of truth for whether a given - // attribute produces any validation output, including any skips (e.g. - // format checks on struct:field:type attributes). For nested user types - // and required-field checks we keep the structural walk. - res := false - done := errors.New("done") - Walk(ut.Attribute(), func(a *expr.AttributeExpr) error { // nolint: errcheck - // validationCode computes the validation that effectively applies - // to a - including user type alias chain validations - and returns - // the empty string when there is nothing to validate. - if validationCode(a, attCtx, true, false, "x", "x") != "" { - res = true - return done - } - return nil - }) - return res + policy := GoLayoutPolicy{ + Pointer: attCtx.Pointer, + IgnoreRequired: attCtx.IgnoreRequired, + UseDefault: attCtx.UseDefault, + UnionPointer: attCtx.UnionPointer, + ArrayElementPointer: attCtx.ArrayElementPointer, + SumType: attCtx.Scope.IsSumType(), + } + return NeedsValidation(ut.Attribute(), policy) } // There is a case where there is validation but no actual validation code: if diff --git a/codegen/validation_plan.go b/codegen/validation_plan.go new file mode 100644 index 0000000000..4f13906bb9 --- /dev/null +++ b/codegen/validation_plan.go @@ -0,0 +1,906 @@ +// This file records every validation check and generated function call before +// Goa chooses Go names. It later writes those checks using the stored field +// shapes, rules, and chosen function names. +package codegen + +import ( + "bytes" + "fmt" + "path" + "strings" + "text/template" + + "goa.design/goa/v3/expr" +) + +type ( + // ValidatorBindingRequest describes one validation call for a nested user + // type. Attribute is available only while planning. Layout supplies its + // generated package and chosen declaration. + ValidatorBindingRequest struct { + // Attribute is the nested user type whose validation call is being prepared. + Attribute *expr.AttributeExpr + // Layout is the planned Go layout for Attribute. + Layout *GoTypePlan + // View selects which result fields the nested validator checks. Service + // types and view-specific result copies use the default view, represented by + // the empty string. + View string + } + + // ValidatorDeclarationBinder returns the package-level validation function + // chosen before Goa starts writing files. + ValidatorDeclarationBinder func(ValidatorBindingRequest) (*NameDeclaration, error) + + // ValidationPlanOptions configures one root validation operation. + ValidationPlanOptions struct { + // Required reports whether the root value is required. + Required bool + // Alias validates the root as the underlying value of a user-type alias. + Alias bool + // Bind resolves every nested non-alias user validation call. + Bind ValidatorDeclarationBinder + } + + // ValidationPlan stores the copied checks for one service or view value, + // including calls to validators for nested fields. It keeps expression + // pointers only to recognize the attribute supplied by the caller. + ValidationPlan struct { + layout *GoTypePlan + root *validationPlanNode + declarations []*NameDeclaration + } + + // LinkedValidationPlan renders a ValidationPlan after Goa has chosen all + // generated function names and package aliases. + LinkedValidationPlan struct { + plan *ValidationPlan + layout LinkedGoType + } + + // validationPlanNode stores checks for one value and for its fields, + // collection entries, or union branches. + validationPlanNode struct { + occurrence *expr.AttributeExpr + layout *GoTypePlan + rules validationRulePlan + guard bool + call *validatorCallPlan + fields []validationFieldPlan + array *validationArrayPlan + mapValue *validationMapPlan + union *validationUnionPlan + } + + // validationRulePlan stores local effective validation values in template + // execution order. + validationRulePlan struct { + values []any + format string + pattern string + exclusiveMinimum *float64 + minimum *float64 + exclusiveMaximum *float64 + maximum *float64 + minLength *int + maxLength *int + required []validationRequiredPlan + pointer bool + dereference bool + aliasCast string + stringValue bool + arrayValue bool + mapValue bool + } + + // validationRequiredPlan stores one generated required-field check. + validationRequiredPlan struct { + name string + fieldName string + unionKind bool + } + + // validationFieldPlan stores one object child and its context path segment. + validationFieldPlan struct { + name string + node *validationPlanNode + } + + // validationArrayPlan stores one element operation and presence policy. + validationArrayPlan struct { + element *validationPlanNode + checkNilElements bool + } + + // validationMapPlan stores map key and value validation operations. + validationMapPlan struct { + key *validationPlanNode + value *validationPlanNode + } + + // validationUnionPlan stores generated sum-type branch operations. + validationUnionPlan struct { + cases []validationUnionCasePlan + } + + // validationUnionCasePlan stores one sum-type accessor and branch program. + validationUnionCasePlan struct { + typeTag string + fieldName string + node *validationPlanNode + } + + // validatorCallPlan stores one exact nested validator declaration. + validatorCallPlan struct { + declaration *NameDeclaration + } + + // validationPlanner performs all expression reads while validation checks and + // function calls are copied into a plan. + validationPlanner struct { + bind ValidatorDeclarationBinder + declarations []*NameDeclaration + } +) + +// NewValidationPlan records every check needed for attribute before Goa chooses +// the final generated names. layout must describe the same attribute and the +// requested service or view representation. +func NewValidationPlan(attribute *expr.AttributeExpr, layout *GoTypePlan, options ValidationPlanOptions) (*ValidationPlan, error) { + if attribute == nil { + return nil, fmt.Errorf("plan validation: attribute must not be nil") + } + if layout == nil { + return nil, fmt.Errorf("plan validation: Go type layout must not be nil") + } + if !layout.MatchesOccurrence(attribute) { + return nil, fmt.Errorf("plan validation: Go type layout does not match the root attribute occurrence") + } + if !layout.Policy().SumType { + return nil, fmt.Errorf("plan validation: service/view validation requires a sum-type Go layout") + } + planner := validationPlanner{bind: options.Bind} + root, err := planner.plan(attribute, layout, options.Required, options.Alias, false, "root") + if err != nil { + return nil, err + } + return &ValidationPlan{ + layout: layout, + root: root, + declarations: planner.declarations, + }, nil +} + +// NeedsValidation reports whether Goa would generate at least one validation +// check for attribute with the given Go field layout. +func NeedsValidation(attribute *expr.AttributeExpr, policy GoLayoutPolicy) bool { + return attributeNeedsValidation(attribute, policy, make(map[expr.UserType]struct{})) +} + +// ValidatorDeclarations returns the exact nested validator declarations in +// stable call order. Repeated calls deliberately repeat the same pointer. +func (p *ValidationPlan) ValidatorDeclarations() []*NameDeclaration { + return append([]*NameDeclaration(nil), p.declarations...) +} + +// ImportPreferences returns each package needed by the stored validation +// checks. Goa and standard library packages keep the names used by the +// templates. A package containing another generated validator includes the +// name Goa should try first. +func (p *ValidationPlan) ImportPreferences() []GoTypeImport { + seen := make(map[string]struct{}) + var imports []GoTypeImport + add := func(goImport GoTypeImport) { + if _, exists := seen[goImport.Path]; exists { + return + } + seen[goImport.Path] = struct{}{} + imports = append(imports, goImport) + } + if p.root.usesUTF8() { + add(GoTypeImport{Path: "unicode/utf8"}) + } + if !p.root.empty() { + goa := GoaImport("") + add(GoTypeImport{Name: goa.Name, Path: goa.Path}) + } + for _, declaration := range p.declarations { + owner := declaration.packagePath() + if owner == p.layout.Owner() { + continue + } + add(GoTypeImport{ + Name: strings.ToLower(Goify(path.Base(owner), false)), + Path: owner, + }) + } + return imports +} + +// Link joins p with the Go types and package aliases that Goa chose for the +// generated file. +func (p *ValidationPlan) Link(layout LinkedGoType) (LinkedValidationPlan, error) { + if layout.plan != p.layout { + return LinkedValidationPlan{}, fmt.Errorf("link validation: linked Go type does not belong to this validation plan") + } + return LinkedValidationPlan{plan: p, layout: layout}, nil +} + +// Render returns validation code for target. context is the root name included +// in validation errors and may differ from the Go target expression. +func (p LinkedValidationPlan) Render(target, context string) string { + return p.renderNode(p.plan.root, target, context) +} + +// Imports returns each external validation package once, using its final alias. +// Imports already supplied by the linked Go layout are not +// repeated unless validation calls require them too. +func (p LinkedValidationPlan) Imports() []GoTypeImport { + preferences := p.plan.ImportPreferences() + if len(preferences) == 0 { + return nil + } + imports := make([]GoTypeImport, len(preferences)) + for index, preference := range preferences { + imports[index] = GoTypeImport{ + Name: p.layout.qualify(preference.Path), + Path: preference.Path, + } + } + return imports +} + +// plan copies one recursive operation. nested distinguishes a user-type field +// call from a root definition whose anonymous layout is expanded in place. +func (p *validationPlanner) plan(attribute *expr.AttributeExpr, layout *GoTypePlan, required, alias, nested bool, path string) (*validationPlanNode, error) { + if !layout.MatchesOccurrence(attribute) { + return nil, fmt.Errorf("plan validation for %s: Go type layout occurrence does not match", path) + } + policy := layout.Policy() + if userType, named := attribute.Type.(expr.UserType); named && !alias && nested { + if !userTypeNeedsValidation(userType, policy, make(map[expr.UserType]struct{})) { + return &validationPlanNode{occurrence: attribute, layout: layout}, nil + } + if p.bind == nil { + return nil, fmt.Errorf("plan validation for %s: validator binder must not be nil", path) + } + declaration, err := p.bind(ValidatorBindingRequest{ + Attribute: attribute, + Layout: layout, + View: "", + }) + if err != nil { + return nil, fmt.Errorf("plan validation for %s: %w", path, err) + } + if declaration == nil { + return nil, fmt.Errorf("plan validation for %s: validator declaration must not be nil", path) + } + if declaration.owner == nil { + return nil, fmt.Errorf("plan validation for %s: validator declaration is not owned", path) + } + if declaration.packagePath() != layout.Owner() { + return nil, fmt.Errorf( + "plan validation for %s: validator owner %q does not match layout owner %q", + path, declaration.packagePath(), layout.Owner(), + ) + } + p.declarations = append(p.declarations, declaration) + return &validationPlanNode{ + occurrence: attribute, + layout: layout, + call: &validatorCallPlan{declaration: declaration}, + }, nil + } + + node := &validationPlanNode{ + occurrence: attribute, + layout: layout, + rules: planValidationRules(attribute, layout, required, alias), + } + switch { + case expr.IsObject(attribute.Type): + object := expr.AsObject(attribute.Type) + fields := layout.Fields() + if len(fields) != len(*object) { + return nil, fmt.Errorf("plan validation for %s: object layout has %d fields, expected %d", path, len(fields), len(*object)) + } + node.fields = make([]validationFieldPlan, 0, len(fields)) + for index, field := range *object { + child, err := p.plan( + field.Attribute, + fields[index], + attribute.IsRequired(field.Name), + expr.IsAlias(field.Attribute.Type), + true, + fmt.Sprintf("field %q", field.Name), + ) + if err != nil { + return nil, err + } + if child.empty() { + continue + } + node.fields = append(node.fields, validationFieldPlan{name: field.Name, node: child}) + } + case expr.IsArray(attribute.Type): + array := expr.AsArray(attribute.Type) + childLayout := layout.Elem() + if childLayout == nil { + return nil, fmt.Errorf("plan validation for %s: array layout has no element", path) + } + childPolicy := policy + if expr.IsPrimitive(array.ElemType.Type) { + childPolicy.Pointer = childLayout.definitionPointer + } + childLayout = childLayout.withPolicy(childPolicy) + child, err := p.plan(array.ElemType, childLayout, true, expr.IsAlias(array.ElemType.Type), true, path+"[*]") + if err != nil { + return nil, err + } + checkNilElements := array.NonNullableElems && + (childLayout.definitionPointer || IsNilable(array.ElemType.Type)) + if !child.empty() || checkNilElements { + node.array = &validationArrayPlan{ + element: child, + checkNilElements: checkNilElements, + } + } + case expr.IsMap(attribute.Type): + mapping := expr.AsMap(attribute.Type) + childPolicy := policy + childPolicy.Pointer = false + keyLayout := layout.Key() + valueLayout := layout.Elem() + if keyLayout == nil || valueLayout == nil { + return nil, fmt.Errorf("plan validation for %s: map layout is incomplete", path) + } + key, err := p.plan(mapping.KeyType, keyLayout.withPolicy(childPolicy), true, expr.IsAlias(mapping.KeyType.Type), true, path+".key") + if err != nil { + return nil, err + } + value, err := p.plan(mapping.ElemType, valueLayout.withPolicy(childPolicy), true, expr.IsAlias(mapping.ElemType.Type), true, path+"[key]") + if err != nil { + return nil, err + } + if !key.empty() || !value.empty() { + node.mapValue = &validationMapPlan{key: key, value: value} + } + case expr.IsUnion(attribute.Type): + union := expr.AsUnion(attribute.Type) + branches := layout.Branches() + if len(branches) != len(union.Values) { + return nil, fmt.Errorf("plan validation for %s: union layout has %d branches, expected %d", path, len(branches), len(union.Values)) + } + var cases []validationUnionCasePlan + for index, branch := range union.Values { + branchPolicy := policy + branchPolicy.Pointer = branchPolicy.Pointer && expr.IsObject(branch.Attribute.Type) + child, err := p.plan( + branch.Attribute, + branches[index].withPolicy(branchPolicy), + true, + expr.IsAlias(branch.Attribute.Type), + true, + fmt.Sprintf("union branch %q", branch.Name), + ) + if err != nil { + return nil, err + } + if child.empty() { + continue + } + cases = append(cases, validationUnionCasePlan{ + typeTag: branch.Name, + fieldName: Goify(branch.Name, true), + node: child, + }) + } + if len(cases) > 0 { + node.union = &validationUnionPlan{cases: cases} + } + } + if nested && !node.empty() { + node.guard = validationNeedsNilGuard(attribute, required, policy) + } + return node, nil +} + +// planValidationRules copies every local effective validation rule. +func planValidationRules(attribute *expr.AttributeExpr, layout *GoTypePlan, required, alias bool) validationRulePlan { + validation := expr.EffectiveValidation(attribute) + if validation == nil { + return validationRulePlan{} + } + policy := layout.Policy() + unaliased := unalias(attribute.Type) + pointer := policy.Pointer || !required && (attribute.DefaultValue == nil || !policy.UseDefault) + rules := validationRulePlan{ + format: string(validation.Format), + pattern: validation.Pattern, + exclusiveMinimum: copyValidationFloat(validation.ExclusiveMinimum), + minimum: copyValidationFloat(validation.Minimum), + exclusiveMaximum: copyValidationFloat(validation.ExclusiveMaximum), + maximum: copyValidationFloat(validation.Maximum), + minLength: copyValidationInt(validation.MinLength), + maxLength: copyValidationInt(validation.MaxLength), + pointer: pointer, + dereference: pointer && expr.IsPrimitive(attribute.Type) && + unaliased.Kind() != expr.BytesKind && unaliased.Kind() != expr.AnyKind, + stringValue: unaliased.Kind() == expr.StringKind, + arrayValue: expr.IsArray(attribute.Type), + mapValue: expr.IsMap(attribute.Type), + } + if validation.Values != nil { + rules.values = make([]any, len(validation.Values)) + for index, value := range validation.Values { + rules.values[index] = copyValidationValue(value) + } + } + if custom, _ := GetMetaType(attribute); custom != "" { + rules.format = "" + } + if alias { + rules.aliasCast = unaliased.Name() + } + object := expr.AsObject(attribute.Type) + fields := layout.Fields() + for _, name := range generatedRequiredValidationNames(attribute, validation, policy) { + var fieldName string + for index, field := range *object { + if field.Name == name { + fieldName = fields[index].FieldName(true) + break + } + } + requiredAttribute := object.Attribute(name) + rules.required = append(rules.required, validationRequiredPlan{ + name: name, + fieldName: fieldName, + unionKind: expr.IsUnion(requiredAttribute.Type) && + policy.SumType && !(policy.UnionPointer && policy.Pointer), + }) + } + return rules +} + +// generatedRequiredValidationNames retains required checks emitted for policy. +func generatedRequiredValidationNames(attribute *expr.AttributeExpr, validation *expr.ValidationExpr, policy GoLayoutPolicy) []string { + object := expr.AsObject(attribute.Type) + var names []string + for _, name := range validation.Required { + required := object.Attribute(name) + if required == nil { + continue + } + if !policy.Pointer && expr.IsPrimitive(required.Type) && + required.Type.Kind() != expr.BytesKind && required.Type.Kind() != expr.AnyKind { + continue + } + if policy.IgnoreRequired && expr.IsPrimitive(required.Type) { + continue + } + names = append(names, name) + } + return names +} + +// validationNeedsNilGuard reports whether generated checks must first verify +// that the value is not nil. +func validationNeedsNilGuard(attribute *expr.AttributeExpr, required bool, policy GoLayoutPolicy) bool { + if expr.IsArray(attribute.Type) || expr.IsMap(attribute.Type) { + return false + } + if expr.IsUnion(attribute.Type) { + return policy.UnionPointer && (!required || policy.Pointer) + } + return policy.Pointer || !required && (attribute.DefaultValue == nil || !policy.UseDefault) +} + +// userTypeNeedsValidation reports whether a user-defined type or any value +// inside it needs a generated check. seen stops recursive types. +func userTypeNeedsValidation(userType expr.UserType, policy GoLayoutPolicy, seen map[expr.UserType]struct{}) bool { + origin := userType.Origin() + if _, exists := seen[origin]; exists { + return false + } + seen[origin] = struct{}{} + defer delete(seen, origin) + return attributeNeedsValidation(userType.Attribute(), policy, seen) +} + +// attributeNeedsValidation reports whether Goa would generate a check for the +// attribute or a value inside it. +func attributeNeedsValidation(attribute *expr.AttributeExpr, policy GoLayoutPolicy, seen map[expr.UserType]struct{}) bool { + validation := expr.EffectiveValidation(attribute) + if validation != nil { + if len(validation.Values) > 0 || validation.Pattern != "" || + validation.ExclusiveMinimum != nil || validation.Minimum != nil || + validation.ExclusiveMaximum != nil || validation.Maximum != nil || + validation.MinLength != nil || validation.MaxLength != nil { + return true + } + if validation.Format != "" { + if custom, _ := GetMetaType(attribute); custom == "" { + return true + } + } + if len(generatedRequiredValidationNames(attribute, validation, policy)) > 0 { + return true + } + } + switch { + case expr.IsObject(attribute.Type): + for _, field := range *expr.AsObject(attribute.Type) { + if nested, ok := field.Attribute.Type.(expr.UserType); ok && !expr.IsAlias(nested) { + if userTypeNeedsValidation(nested, policy, seen) { + return true + } + continue + } + if attributeNeedsValidation(field.Attribute, policy, seen) { + return true + } + } + case expr.IsArray(attribute.Type): + array := expr.AsArray(attribute.Type) + if array.NonNullableElems && + (IsNilable(array.ElemType.Type) || arrayElementIsPointer(array, policy.ArrayElementPointer)) { + return true + } + return attributeNeedsValidation(array.ElemType, policy, seen) + case expr.IsMap(attribute.Type): + mapping := expr.AsMap(attribute.Type) + mapPolicy := policy + mapPolicy.Pointer = false + return attributeNeedsValidation(mapping.KeyType, mapPolicy, seen) || + attributeNeedsValidation(mapping.ElemType, mapPolicy, seen) + case expr.IsUnion(attribute.Type): + for _, branch := range expr.AsUnion(attribute.Type).Values { + branchPolicy := policy + branchPolicy.Pointer = policy.Pointer && expr.IsObject(branch.Attribute.Type) + if nested, ok := branch.Attribute.Type.(expr.UserType); ok && !expr.IsAlias(nested) { + if userTypeNeedsValidation(nested, branchPolicy, seen) { + return true + } + continue + } + if attributeNeedsValidation(branch.Attribute, branchPolicy, seen) { + return true + } + } + } + return false +} + +// renderNode writes the Go checks for node and its children without reading the +// original design expression. +func (p LinkedValidationPlan) renderNode(node *validationPlanNode, target, context string) string { + if node.call != nil { + name := p.validatorName(node.call.declaration) + var buffer bytes.Buffer + if err := userValT.Execute(&buffer, map[string]any{ + "call": fmt.Sprintf("%s(%s)", name, target), + "goa": p.goaPackage(), + }); err != nil { + panic(err) + } + return fmt.Sprintf("if %s != nil {\n\t%s\n}", target, buffer.String()) + } + var sections []string + if local := p.renderValidationRules(node.rules, target, context, !node.guard); local != "" { + sections = append(sections, local) + } + for _, field := range node.fields { + validation := p.renderNode( + field.node, + target+"."+field.node.layout.FieldName(true), + context+"."+field.name, + ) + if validation != "" { + sections = append(sections, validation) + } + } + if node.array != nil { + validation := p.renderNode(node.array.element, "e", context+"[*]") + var buffer bytes.Buffer + if err := arrayValT.Execute(&buffer, map[string]any{ + "target": target, + "validation": validation, + "checkNilElements": node.array.checkNilElements, + "context": literalValidationPath(context), + "goa": p.goaPackage(), + }); err != nil { + panic(err) + } + sections = append(sections, buffer.String()) + } + if node.mapValue != nil { + keyValidation := p.renderNode(node.mapValue.key, "k", context+".key") + if keyValidation != "" { + keyValidation = "\n" + keyValidation + } + valueValidation := p.renderNode(node.mapValue.value, "v", context+"[key]") + if valueValidation != "" { + valueValidation = "\n" + valueValidation + } + var buffer bytes.Buffer + if err := mapValT.Execute(&buffer, map[string]any{ + "target": target, + "keyValidation": keyValidation, + "valueValidation": valueValidation, + }); err != nil { + panic(err) + } + sections = append(sections, buffer.String()) + } + if node.union != nil { + cases := make([]map[string]any, len(node.union.cases)) + for index, unionCase := range node.union.cases { + cases[index] = map[string]any{ + "typeTag": unionCase.typeTag, + "fieldName": unionCase.fieldName, + "validation": p.renderNode(unionCase.node, "actual", context+".value"), + } + } + var buffer bytes.Buffer + if err := unionSumValT.Execute(&buffer, map[string]any{"target": target, "cases": cases}); err != nil { + panic(err) + } + sections = append(sections, buffer.String()) + } + code := strings.Join(sections, "\n") + if node.guard && code != "" { + condition := fmt.Sprintf("if %s != nil {\n", target) + code = condition + code + "\n}" + } + return code +} + +// renderValidationRules renders copied local rules through the shared +// validation templates using the package names assigned to the linked file. +func (p LinkedValidationPlan) renderValidationRules(rules validationRulePlan, target, context string, localGuards bool) string { + targetValue := target + if rules.dereference { + targetValue = "*" + targetValue + } + if rules.aliasCast != "" { + targetValue = fmt.Sprintf("%s(%s)", rules.aliasCast, targetValue) + } + utf8Package := "" + if rules.stringValue && (rules.minLength != nil || rules.maxLength != nil) { + utf8Package = p.utf8Package() + } + data := map[string]any{ + "isPointer": rules.pointer && localGuards, + "context": literalValidationPath(context), + "target": target, + "targetVal": targetValue, + "goa": p.goaPackage(), + "utf8": utf8Package, + "string": rules.stringValue, + "array": rules.arrayValue, + "map": rules.mapValue, + } + var rendered []string + if rules.values != nil { + data["values"] = rules.values + rendered = appendValidationTemplate(rendered, enumValT, data) + } + if rules.format != "" { + data["format"] = rules.format + rendered = appendValidationTemplate(rendered, formatValT, data) + } + if rules.pattern != "" { + data["pattern"] = rules.pattern + rendered = appendValidationTemplate(rendered, patternValT, data) + } + if rules.exclusiveMinimum != nil { + data["exclMin"] = *rules.exclusiveMinimum + data["isExclMin"] = true + rendered = appendValidationTemplate(rendered, exclMinMaxValT, data) + } + if rules.minimum != nil { + data["min"] = *rules.minimum + data["isMin"] = true + rendered = appendValidationTemplate(rendered, minMaxValT, data) + } + if rules.exclusiveMaximum != nil { + data["exclMax"] = *rules.exclusiveMaximum + data["isExclMin"] = false + rendered = appendValidationTemplate(rendered, exclMinMaxValT, data) + } + if rules.maximum != nil { + data["max"] = *rules.maximum + data["isMin"] = false + rendered = appendValidationTemplate(rendered, minMaxValT, data) + } + if rules.minLength != nil { + data["minLength"] = rules.minLength + data["isMinLength"] = true + delete(data, "maxLength") + rendered = appendValidationTemplate(rendered, lengthValT, data) + } + if rules.maxLength != nil { + data["maxLength"] = rules.maxLength + data["isMinLength"] = false + delete(data, "minLength") + rendered = appendValidationTemplate(rendered, lengthValT, data) + } + for _, required := range rules.required { + if required.unionKind { + rendered = append(rendered, fmt.Sprintf( + "if %s.%s.Kind() == \"\" {\n err = %s.MergeErrors(err, %s.MissingFieldError(%q, %q))\n}", + target, required.fieldName, p.goaPackage(), p.goaPackage(), required.name, context, + )) + continue + } + rendered = append(rendered, fmt.Sprintf( + "if %s.%s == nil {\n err = %s.MergeErrors(err, %s.MissingFieldError(%q, %q))\n}", + target, required.fieldName, p.goaPackage(), p.goaPackage(), required.name, context, + )) + } + return strings.Join(rendered, "\n") +} + +// appendValidationTemplate executes one shared local validation template. +func appendValidationTemplate(rendered []string, validationTemplate *template.Template, data map[string]any) []string { + var buffer bytes.Buffer + if err := validationTemplate.Execute(&buffer, data); err != nil { + panic(err) + } + if validation := strings.Trim(buffer.String(), "\n"); validation != "" { + return append(rendered, validation) + } + return rendered +} + +// validatorName qualifies one exact validator declaration for the linked file. +func (p LinkedValidationPlan) validatorName(declaration *NameDeclaration) string { + name := declaration.Name() + owner := declaration.packagePath() + if owner == p.layout.outputPath { + return name + } + return p.layout.qualify(owner) + "." + name +} + +// goaPackage returns the final name of Goa's generated-error package. +func (p LinkedValidationPlan) goaPackage() string { + return p.layout.qualify(GoaImport("").Path) +} + +// utf8Package returns the final name of the standard UTF-8 package. +func (p LinkedValidationPlan) utf8Package() string { + return p.layout.qualify("unicode/utf8") +} + +// empty reports whether node emits any validation code. +func (n *validationPlanNode) empty() bool { + return n.call == nil && n.rules.empty() && len(n.fields) == 0 && + n.array == nil && n.mapValue == nil && n.union == nil +} + +// usesUTF8 reports whether this validation tree counts runes in a string. +func (n *validationPlanNode) usesUTF8() bool { + if n.rules.stringValue && (n.rules.minLength != nil || n.rules.maxLength != nil) { + return true + } + for _, field := range n.fields { + if field.node.usesUTF8() { + return true + } + } + if n.array != nil && n.array.element.usesUTF8() { + return true + } + if n.mapValue != nil && (n.mapValue.key.usesUTF8() || n.mapValue.value.usesUTF8()) { + return true + } + if n.union != nil { + for _, unionCase := range n.union.cases { + if unionCase.node.usesUTF8() { + return true + } + } + } + return false +} + +// empty reports whether these rules would write no Go checks. +func (p validationRulePlan) empty() bool { + return p.values == nil && p.format == "" && p.pattern == "" && + p.exclusiveMinimum == nil && p.minimum == nil && + p.exclusiveMaximum == nil && p.maximum == nil && + p.minLength == nil && p.maxLength == nil && len(p.required) == 0 +} + +// withPolicy copies the prepared type and changes only the rules used to write +// its Go value. It does not modify the original type description. +func (p *GoTypePlan) withPolicy(policy GoLayoutPolicy) *GoTypePlan { + clone := *p + clone.policy = policy + if p.key != nil { + clone.key = p.key.withPolicy(policy) + } + if p.element != nil { + clone.element = p.element.withPolicy(policy) + } + if len(p.fields) > 0 { + clone.fields = make([]*GoTypePlan, len(p.fields)) + for index, field := range p.fields { + clone.fields[index] = field.withPolicy(policy) + } + } + if len(p.branches) > 0 { + clone.branches = make([]*GoTypePlan, len(p.branches)) + for index, branch := range p.branches { + clone.branches[index] = branch.withPolicy(policy) + } + } + return &clone +} + +// copyValidationFloat copies one optional scalar rule value. +func copyValidationFloat(value *float64) *float64 { + if value == nil { + return nil + } + copy := *value + return © +} + +// copyValidationInt copies one optional length rule value. +func copyValidationInt(value *int) *int { + if value == nil { + return nil + } + copy := *value + return © +} + +// copyValidationValue detaches the mutable collection shapes accepted by Goa +// enum validations. Primitive values are immutable and remain shared. +func copyValidationValue(value any) any { + switch actual := value.(type) { + case expr.Val: + copied := make(expr.Val, len(actual)) + for name, child := range actual { + copied[name] = copyValidationValue(child) + } + return copied + case expr.ArrayVal: + copied := make(expr.ArrayVal, len(actual)) + for index, child := range actual { + copied[index] = copyValidationValue(child) + } + return copied + case expr.MapVal: + copied := make(expr.MapVal, len(actual)) + for key, child := range actual { + copied[copyValidationValue(key)] = copyValidationValue(child) + } + return copied + case []any: + copied := make([]any, len(actual)) + for index, child := range actual { + copied[index] = copyValidationValue(child) + } + return copied + case []byte: + return append([]byte(nil), actual...) + case map[string]any: + copied := make(map[string]any, len(actual)) + for name, child := range actual { + copied[name] = copyValidationValue(child) + } + return copied + case map[any]any: + copied := make(map[any]any, len(actual)) + for key, child := range actual { + copied[copyValidationValue(key)] = copyValidationValue(child) + } + return copied + default: + return actual + } +} diff --git a/codegen/validation_plan_test.go b/codegen/validation_plan_test.go new file mode 100644 index 0000000000..9367cd25e9 --- /dev/null +++ b/codegen/validation_plan_test.go @@ -0,0 +1,566 @@ +// This file verifies that validation planning preserves service and view +// output without reading expressions after package names are fixed. +package codegen + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestValidationPlanPreservesRulesPathsAndRequiredness compares copied rule +// rendering with the existing service/view validation generator. +func TestValidationPlanPreservesRulesPathsAndRequiredness(t *testing.T) { + minimum := 2.0 + minLength := 3 + attribute := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{ + Pattern: "^[a-z]+$", + MinLength: &minLength, + }, + }}, + {Name: "count", Attribute: &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }}, + {Name: "nested", Attribute: &expr.AttributeExpr{Type: &expr.Object{}}}, + }, + Validation: &expr.ValidationExpr{Required: []string{"name", "nested"}}, + } + policy := GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true} + legacyContext := NewAttributeContext( + policy.Pointer, + policy.IgnoreRequired, + policy.UseDefault, + "", + NewNameScope(), + ) + legacyContext.UnionPointer = policy.UnionPointer + want := ValidationCode(attribute, nil, legacyContext, true, false, true, "target") + + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + + attribute.Validation = nil + attribute.Type = expr.String + minimum = 99 + minLength = 99 + + linked, err := plan.Link(layout.Link("generated.local/gen/service", validationPlanTestQualifier)) + require.NoError(t, err) + require.Equal(t, want, linked.Render("target", "target")) + require.Equal(t, []GoTypeImport{ + {Name: "utf8", Path: "unicode/utf8"}, + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, linked.Imports()) +} + +// TestValidationPlanImportsOnlyUsedRuntimePackages checks that standalone +// validation users receive the packages named directly by rendered checks. +func TestValidationPlanImportsOnlyUsedRuntimePackages(t *testing.T) { + for _, test := range []struct { + name string + validation *expr.ValidationExpr + wantPreferences []GoTypeImport + wantImports []GoTypeImport + }{ + { + name: "no checks", + validation: nil, + }, + { + name: "pattern", + validation: &expr.ValidationExpr{Pattern: "^[a-z]+$"}, + wantPreferences: []GoTypeImport{ + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, + wantImports: []GoTypeImport{ + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, + }, + { + name: "string length", + validation: func() *expr.ValidationExpr { + minimum := 2 + return &expr.ValidationExpr{MinLength: &minimum} + }(), + wantPreferences: []GoTypeImport{ + {Path: "unicode/utf8"}, + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, + wantImports: []GoTypeImport{ + {Name: "utf8", Path: "unicode/utf8"}, + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String, Validation: test.validation} + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + require.Equal(t, test.wantPreferences, plan.ImportPreferences()) + linked, err := plan.Link(layout.Link(layout.Owner(), validationPlanTestQualifier)) + require.NoError(t, err) + require.Equal(t, test.wantImports, linked.Imports()) + }) + } +} + +// TestValidationPlanImportPreferencesIncludeExternalValidators checks that +// planning includes only packages containing validation functions that the +// generated checks call. +func TestValidationPlanImportPreferencesIncludeExternalValidators(t *testing.T) { + const ( + owner = "generated.local/gen/service" + childOwner = "generated.local/gen/shared" + ) + minimum := 1.0 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "count", Attribute: &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }}, + }) + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + childDeclaration := declareGoTypeTestUserType(t, generation, childOwner, child) + validator := NewExactName(NameFunction, "ValidateChild") + require.NoError(t, generation.Package(childOwner).DeclareName(validator)) + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: child}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: child}}, + }} + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + child: {Owner: childOwner, Type: childDeclaration}, + }), + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{ + Required: true, + Bind: func(ValidatorBindingRequest) (*NameDeclaration, error) { + return validator, nil + }, + }) + require.NoError(t, err) + + require.Equal(t, []GoTypeImport{ + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + {Name: "shared", Path: childOwner}, + }, plan.ImportPreferences()) +} + +// TestValidationPlanUsesFinalRuntimeImportNames proves rendered checks and +// reported imports use the same collision-safe package names. +func TestValidationPlanUsesFinalRuntimeImportNames(t *testing.T) { + minimum := 2 + attribute := &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + } + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link(layout.Owner(), func(importPath string) string { + switch importPath { + case "goa.design/goa/v3/pkg": + return "goa2" + case "unicode/utf8": + return "utf82" + default: + t.Fatalf("unexpected validation import %q", importPath) + return "" + } + })) + require.NoError(t, err) + + require.Equal(t, []GoTypeImport{ + {Name: "utf82", Path: "unicode/utf8"}, + {Name: "goa2", Path: "goa.design/goa/v3/pkg"}, + }, linked.Imports()) + code := linked.Render("target", "target") + require.Contains(t, code, "utf82.RuneCountInString") + require.Contains(t, code, "goa2.MergeErrors") +} + +// TestValidationPlanSharesOptionalFieldGuard verifies that copied validation +// rules use the one nil check selected for their containing field. +func TestValidationPlanSharesOptionalFieldGuard(t *testing.T) { + minLength := 2 + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{ + Pattern: "^[a-z]+$", + MinLength: &minLength, + }, + }}, + }} + policy := GoLayoutPolicy{UseDefault: true, SumType: true} + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link(layout.Owner(), validationPlanTestQualifier)) + require.NoError(t, err) + + code := linked.Render("target", "target") + require.Equal(t, 1, strings.Count(code, "if target.Name != nil")) + require.Contains(t, code, "goa.ValidatePattern") + require.Contains(t, code, "goa.InvalidLengthError") +} + +// TestValidationPlanCopiesEnumValues verifies that accepted mutable enum +// values cannot change a validation program after planning. +func TestValidationPlanCopiesEnumValues(t *testing.T) { + bytesValue := []byte{1, 2} + arrayValue := []any{ + bytesValue, + map[string]any{"nested": []any{"kept"}}, + } + mapValue := map[string]any{"array": arrayValue} + attribute := &expr.AttributeExpr{ + Type: expr.Any, + Validation: &expr.ValidationExpr{Values: []any{ + bytesValue, + arrayValue, + mapValue, + }}, + } + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + + bytesValue[0] = 9 + arrayValue[1].(map[string]any)["nested"].([]any)[0] = "changed" + mapValue["added"] = true + attribute.Validation.Values[0] = "replaced" + + require.Equal(t, []any{ + []byte{1, 2}, + []any{ + []byte{1, 2}, + map[string]any{"nested": []any{"kept"}}, + }, + map[string]any{"array": []any{ + []byte{1, 2}, + map[string]any{"nested": []any{"kept"}}, + }}, + }, plan.root.rules.values) +} + +// TestNeedsValidation reports whether the validation renderer can write code +// for local rules, nested rules, and values with no rules. +func TestNeedsValidation(t *testing.T) { + minimum := 1.0 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "count", Attribute: &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }}, + }) + tests := []struct { + name string + attribute *expr.AttributeExpr + want bool + }{ + { + name: "local rule", + attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: ".+"}, + }, + want: true, + }, + { + name: "nested rule", + attribute: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: child}}, + }}, + want: true, + }, + { + name: "no rules", + attribute: &expr.AttributeExpr{Type: &expr.Object{{Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}}}, + }, + } + policy := GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, NeedsValidation(test.attribute, policy)) + }) + } +} + +// TestValidationPlanChecksOnlyRepresentableNullElements verifies that a null +// check follows the generated element type instead of the raw DSL flag. +func TestValidationPlanChecksOnlyRepresentableNullElements(t *testing.T) { + array := &expr.Array{ + ElemType: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: "[a-z]+"}, + }, + NonNullableElems: true, + } + attribute := &expr.AttributeExpr{Type: array} + tests := []struct { + name string + jsonBody bool + wantCheck bool + }{ + {name: "service values"}, + {name: "JSON input pointers", jsonBody: true, wantCheck: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + policy := GoLayoutPolicy{ + UseDefault: true, + SumType: true, + ArrayElementPointer: test.jsonBody, + } + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link(layout.Owner(), validationPlanTestQualifier)) + require.NoError(t, err) + code := linked.Render("target", "target") + require.Equal(t, test.wantCheck, strings.Contains(code, "e == nil")) + if test.jsonBody { + require.Contains(t, code, "goa.ValidatePattern(\"target[*]\", *e, \"[a-z]+\")") + } else { + require.Contains(t, code, "goa.ValidatePattern(\"target[*]\", e, \"[a-z]+\")") + } + require.True(t, NeedsValidation(attribute, policy)) + }) + } + + objectArray := &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: &expr.Object{}}, + NonNullableElems: true, + }} + policy := GoLayoutPolicy{UseDefault: true, SumType: true} + layout, err := PlanGoType(objectArray, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(objectArray, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link(layout.Owner(), validationPlanTestQualifier)) + require.NoError(t, err) + require.Contains(t, linked.Render("target", "target"), "e == nil") + require.True(t, NeedsValidation(objectArray, policy)) +} + +// TestNeedsValidationChecksEverySiblingCopy verifies that one unconstrained +// copy of a type does not hide rules on another copy of the same type. +func TestNeedsValidationChecksEverySiblingCopy(t *testing.T) { + minLength := 2 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + unvalidated := expr.DupAtt(&expr.AttributeExpr{Type: child}) + validated := expr.DupAtt(&expr.AttributeExpr{Type: child}) + expr.AsObject(validated.Type.(expr.UserType).Attribute().Type).Attribute("value").Validation = + &expr.ValidationExpr{MinLength: &minLength} + + tests := []struct { + name string + fields *expr.Object + }{ + { + name: "unvalidated copy first", + fields: &expr.Object{ + {Name: "first", Attribute: unvalidated}, + {Name: "second", Attribute: validated}, + }, + }, + { + name: "validated copy first", + fields: &expr.Object{ + {Name: "first", Attribute: validated}, + {Name: "second", Attribute: unvalidated}, + }, + }, + } + policy := GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attribute := &expr.AttributeExpr{Type: test.fields} + require.True(t, NeedsValidation(attribute, policy)) + }) + } +} + +// TestValidationPlanPreservesContainersUnionsAndValidatorBindings verifies all +// recursive service/view shapes retain exact nested validator declarations. +func TestValidationPlanPreservesContainersUnionsAndValidatorBindings(t *testing.T) { + const owner = "generated.local/gen/service" + minLength := 1 + minimum := 4.0 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + }}, + }) + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "label", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: ".+"}, + }}, + {Name: "child", Attribute: &expr.AttributeExpr{Type: child}}, + }, + } + mapKey := &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + } + mapValue := &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "children", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: child}, + }}}, + {Name: "labels", Attribute: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: mapKey, + ElemType: mapValue, + }}}, + {Name: "choice", Attribute: &expr.AttributeExpr{Type: union}}, + }} + policy := GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true} + legacyContext := NewAttributeContext( + policy.Pointer, + policy.IgnoreRequired, + policy.UseDefault, + "", + NewNameScope(), + ) + legacyContext.UnionPointer = policy.UnionPointer + want := ValidationCode(attribute, nil, legacyContext, true, false, true, "target") + + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + childDeclaration := declareGoTypeTestUserType(t, generation, owner, child) + unionDeclaration := declareGoTypeTestUnion(t, generation, owner, union) + generatedPackage := generation.Package(owner) + validator := NewExactName(NameFunction, "ValidateChild") + require.NoError(t, generatedPackage.DeclareName(validator)) + + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: owner, + Policy: policy, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + child: {Owner: owner, Type: childDeclaration}, + union: {Owner: owner, Union: unionDeclaration}, + }), + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{ + Required: true, + Bind: func(request ValidatorBindingRequest) (*NameDeclaration, error) { + require.Same(t, child, request.Attribute.Type) + require.Equal(t, owner, request.Layout.Owner()) + require.Empty(t, request.View) + return validator, nil + }, + }) + require.NoError(t, err) + require.Equal(t, []*NameDeclaration{validator, validator}, plan.ValidatorDeclarations()) + + mapKey.Validation = nil + mapValue.Validation = nil + union.Values = nil + child.SetAttribute(&expr.AttributeExpr{Type: expr.String}) + attribute.Type = expr.String + + require.NoError(t, generation.Freeze()) + linked, err := plan.Link(layout.Link(owner, validationPlanTestQualifier)) + require.NoError(t, err) + require.Equal(t, want, linked.Render("target", "target")) + require.Equal(t, []GoTypeImport{ + {Name: "utf8", Path: "unicode/utf8"}, + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, linked.Imports()) +} + +// TestValidationPlanRejectsUnboundNestedValidator verifies planning never +// falls back to reconstructing a validator name from a user type. +func TestValidationPlanRejectsUnboundNestedValidator(t *testing.T) { + minLength := 1 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + }}, + }) + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: child}}, + }} + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + declaration := declareGoTypeTestUserType(t, generation, "generated.local/gen/service", child) + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true}, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + child: {Owner: "generated.local/gen/service", Type: declaration}, + }), + }) + require.NoError(t, err) + + _, err = NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.EqualError(t, err, "plan validation for field \"child\": validator binder must not be nil") +} + +// validationPlanTestQualifier resolves the focused generated package aliases. +func validationPlanTestQualifier(importPath string) string { + switch importPath { + case "generated.local/gen/service": + return "service" + case "goa.design/goa/v3/pkg": + return "goa" + case "unicode/utf8": + return "utf8" + default: + panic(fmt.Sprintf("unexpected validation import %q", importPath)) + } +} diff --git a/codegen/validation_protobuf_union_test.go b/codegen/validation_protobuf_union_test.go new file mode 100644 index 0000000000..02757a810c --- /dev/null +++ b/codegen/validation_protobuf_union_test.go @@ -0,0 +1,101 @@ +// This file checks the validation generated for protobuf OneOf values. +// It rejects a missing selected branch and a selected branch whose value is nil. +package codegen + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +type ( + // protobufUnionTestScope returns the Go names used by the generated struct for + // each selected branch. This lets the test run without generating a service. + protobufUnionTestScope struct { + scope *NameScope + } +) + +func TestProtobufUnionValidationRequiresCompleteSelectedBranch(t *testing.T) { + root := RunDSL(t, protobufUnionValidationDSL) + message := root.UserType("Message") + ctx := NewAttributeContext(false, true, false, "pb", NewNameScope()) + ctx.Scope = &protobufUnionTestScope{scope: NewNameScope()} + + generated := AttributeValidationCode(message.Attribute(), message, ctx, true, false, "message", "message") + + require.Contains(t, generated, `goa.MissingFieldError("choice", "message")`) + require.Contains(t, generated, `goa.MissingFieldError("detail", "message.choice")`) + require.Contains(t, generated, `goa.MissingFieldError("inactive", "message.choice")`) + require.Contains(t, generated, `goa.MissingFieldError("blob", "message.choice")`) + require.Contains(t, generated, `goa.MissingFieldError("metadata", "message.choice")`) + require.Contains(t, generated, "if v == nil {") + require.Contains(t, generated, "if v.Detail == nil {") + require.Contains(t, generated, "if v.Inactive == nil {") + require.Contains(t, generated, "if v.Metadata == nil {") + require.Contains(t, generated, "if v.Blob == nil {") + require.NotContains(t, generated, "if v.Token == nil {") +} + +func (s *protobufUnionTestScope) Name(att *expr.AttributeExpr, pkg string, _, _ bool) string { + name := Goify(att.Type.Name(), true) + if pkg != "" { + return pkg + "." + name + } + return name +} + +func (s *protobufUnionTestScope) Ref(att *expr.AttributeExpr, pkg string) string { + return "*" + s.Name(att, pkg, false, false) +} + +func (*protobufUnionTestScope) Field(_ *expr.AttributeExpr, name string, firstUpper bool) string { + return Goify(name, firstUpper) +} + +func (*protobufUnionTestScope) Package(*expr.AttributeExpr) string { + return "pb" +} + +func (s *protobufUnionTestScope) Enter(*expr.AttributeExpr) Attributor { + return s +} + +func (*protobufUnionTestScope) IsSumType() bool { + return false +} + +func (s *protobufUnionTestScope) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + name := "Validate" + s.Name(att, "", false, false) + Goify(view, true) + return fmt.Sprintf("%s(%s)", name, target) +} + +func (s *protobufUnionTestScope) Scope() *NameScope { + return s.scope +} + +// protobufUnionValidationDSL creates OneOf branches stored as pointers, +// scalars, and byte slices. +func protobufUnionValidationDSL() { + token := d.Type("Token", d.String) + detail := d.Type("Detail", func() { + d.Attribute("label", d.String) + d.Required("label") + }) + inactive := d.Type("Inactive", func() {}) + d.Type("Message", func() { + d.OneOf("choice", func() { + d.Attribute("number", d.Int, func() { d.Minimum(1) }) + d.Attribute("detail", detail) + d.Attribute("inactive", inactive) + d.Attribute("blob", d.Bytes) + d.Attribute("token", token) + d.Attribute("metadata", d.Any) + }) + d.Required("choice") + }) +} diff --git a/codegen/validation_test.go b/codegen/validation_test.go index 0c373a3e7e..77c1cc44ea 100644 --- a/codegen/validation_test.go +++ b/codegen/validation_test.go @@ -1,6 +1,9 @@ +// This file verifies generated validation code for nested attributes, user +// types, unions, and declaration origins. package codegen import ( + "bytes" "strings" "testing" @@ -131,6 +134,138 @@ func TestRecursiveValidationWithCycleGuard(t *testing.T) { } } +// TestRecursiveValidationDistinguishesEqualUIDOrigins verifies that unrelated +// user types with the same semantic UID retain their distinct validation +// shapes when reached in one recursive validation pass. +func TestRecursiveValidationDistinguishesEqualUIDOrigins(t *testing.T) { + minLength := 3 + minimum := 5.0 + first := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{ + Name: "code", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + }, + }, + }}, + TypeName: "First", + UID: "shared", + } + second := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{ + Name: "count", + Attribute: &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }, + }, + }}, + TypeName: "Second", + UID: "shared", + } + ctx := NewAttributeContext(false, false, false, "", NewNameScope()) + seen := make(map[expr.UserType]*bytes.Buffer) + firstCode := recurseValidationCode(&expr.AttributeExpr{Type: first}, nil, ctx, true, false, false, "first", literalValidationPath("first"), seen).String() + secondCode := recurseValidationCode(&expr.AttributeExpr{Type: second}, nil, ctx, true, false, false, "second", literalValidationPath("second"), seen).String() + + require.Contains(t, firstCode, "first.Code") + require.Contains(t, firstCode, "InvalidLengthError") + require.Contains(t, secondCode, "second.Count") + require.Contains(t, secondCode, "InvalidRangeError") + require.Len(t, seen, 2) +} + +// TestValidationPathsAreSpecializedBeforeRendering verifies that fixed roots +// become string literals while reusable validators receive only their caller's +// path as a runtime value. +func TestValidationPathsAreSpecializedBeforeRendering(t *testing.T) { + minLength := 1 + pattern := "^[a-z]+$" + attribute := &expr.AttributeExpr{Type: &expr.Object{ + { + Name: "nested", + Attribute: &expr.AttributeExpr{Type: &expr.Object{ + { + Name: "value", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + }, + }, + }}, + }, + { + Name: "items", + Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: pattern}, + }, + }}, + }, + { + Name: "values", + Attribute: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: pattern}, + }, + }}, + }, + }} + context := NewAttributeContext(true, false, false, "", NewNameScope()) + + direct := ValidationCode(attribute, nil, context, true, false, false, "body") + direct = FormatTestCode(t, "package foo\nfunc validate() (err error) {\n"+direct+"\nreturn\n}") + require.Contains(t, direct, `"body.nested.value"`) + require.Contains(t, direct, `"body.items[*]"`) + require.Contains(t, direct, `"body.values[key]"`) + require.NotContains(t, direct, "path+") + + nested := ValidationCodeWithPathParameter(attribute, nil, context, true, false, false, "body", "path") + nested = FormatTestCode(t, "package foo\nfunc validate(path string) (err error) {\n"+nested+"\nreturn\n}") + require.Contains(t, nested, `path+".nested.value"`) + require.Contains(t, nested, `path+".items[*]"`) + require.Contains(t, nested, `path+".values[key]"`) + require.NotContains(t, nested, "fmt.Sprintf") +} + +// TestValidationCodeSharesOptionalFieldGuard verifies that the direct +// validation renderer checks one optional primitive once before all its rules. +func TestValidationCodeSharesOptionalFieldGuard(t *testing.T) { + minLength := 2 + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{ + Pattern: "^[a-z]+$", + MinLength: &minLength, + }, + }}, + }} + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code := ValidationCode(attribute, nil, context, true, false, false, "target") + require.Equal(t, 1, strings.Count(code, "if target.Name != nil")) + require.Contains(t, code, "goa.ValidatePattern") + require.Contains(t, code, "goa.InvalidLengthError") + + root := &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{ + Pattern: "^[a-z]+$", + MinLength: &minLength, + }, + } + rootCode := ValidationCode(root, nil, context, false, false, false, "target") + require.Equal(t, 2, strings.Count(rootCode, "if target != nil")) + require.Contains(t, rootCode, "*target") +} + // TestMultipleAliasTypesInSameStruct tests that multiple fields with the same // alias type can be validated independently. Previously, the recursion guard // would incorrectly block validation of the second field. @@ -219,6 +354,39 @@ func TestValidationPredicatesPure(t *testing.T) { assertValidationsUnchanged(t, before) } +// TestValidationCodeUsesBothExclusiveBounds verifies that generating a lower +// exclusive bound cannot leave the upper bound on the lower-bound template +// branch. +func TestValidationCodeUsesBothExclusiveBounds(t *testing.T) { + exclusiveMinimum := 1.0 + exclusiveMaximum := 10.0 + attribute := &expr.AttributeExpr{ + Type: expr.Float64, + Validation: &expr.ValidationExpr{ + ExclusiveMinimum: &exclusiveMinimum, + ExclusiveMaximum: &exclusiveMaximum, + }, + } + context := NewAttributeContext(false, false, true, "", NewNameScope()) + legacy := ValidationCode(attribute, nil, context, true, false, true, "target") + require.Contains(t, legacy, "target <= 1") + require.Contains(t, legacy, "target >= 10") + + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link("generated.local/gen/service", func(importPath string) string { + require.Equal(t, "goa.design/goa/v3/pkg", importPath) + return "goa" + })) + require.NoError(t, err) + require.Equal(t, legacy, linked.Render("target", "target")) +} + // validationSnapshot captures the identity and deep value of an attribute // validation so mutations can be detected after running codegen. type validationSnapshot struct { diff --git a/codegen/validation_union_context_test.go b/codegen/validation_union_context_test.go index 4f17d58eda..749bbe3124 100644 --- a/codegen/validation_union_context_test.go +++ b/codegen/validation_union_context_test.go @@ -11,6 +11,14 @@ import ( "goa.design/goa/v3/expr" ) +type ( + // sumTypeTestScope reports that a union is stored directly as a Go value. The + // test checks that generated validation uses this information. + sumTypeTestScope struct { + Attributor + } +) + func TestUnionValidationPreservesValueContextForRequiredOnlyObjectBranches(t *testing.T) { root := RunDSL(t, requiredObjectUnionDSL) scope := NewNameScope() @@ -71,6 +79,32 @@ func TestUnionValidationUsesGeneratedFieldRepresentation(t *testing.T) { require.Contains(t, marshalCode, "if target.Optional != nil {") } +func TestUnionValidationUsesCustomSumTypeResolver(t *testing.T) { + union := &expr.Union{ + TypeName: "Scope", + Values: []*expr.NamedAttributeExpr{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + } + attribute := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "scope", Attribute: &expr.AttributeExpr{Type: union}}, + }, + Validation: &expr.ValidationExpr{Required: []string{"scope"}}, + } + context := NewAttributeContext(false, false, true, "", NewNameScope()) + context.Scope = &sumTypeTestScope{Attributor: context.Scope} + + generated := ValidationCode(attribute, nil, context, true, false, false, "target") + + require.Contains(t, generated, `if target.Scope.Kind() == "" {`) + require.NotContains(t, generated, "if target.Scope == nil {") +} + +func (*sumTypeTestScope) IsSumType() bool { + return true +} + // requiredObjectUnionDSL defines a OneOf with required-only object branches so // validation generation can distinguish pointer and value contexts. func requiredObjectUnionDSL() { diff --git a/codegen/walk.go b/codegen/walk.go index a2192912d4..1e64d3e351 100644 --- a/codegen/walk.go +++ b/codegen/walk.go @@ -1,3 +1,5 @@ +// Attribute walkers visit Goa design types once per source declaration while +// preserving the concrete dynamic type supplied to callbacks. package codegen import "goa.design/goa/v3/expr" @@ -10,13 +12,13 @@ type MappedAttributeWalker func(name, elem string, required bool, a *expr.Attrib // Walk traverses the data structure recursively and calls the given function // once on each attribute starting with a. func Walk(a *expr.AttributeExpr, walker func(*expr.AttributeExpr) error) error { - return walk(a, walker, make(map[string]bool)) + return walk(a, walker, make(map[expr.UserType]struct{})) } // WalkType traverses the data structure recursively and calls the given function // once on each attribute starting with the user type attribute. func WalkType(u expr.UserType, walker func(*expr.AttributeExpr) error) error { - return walk(u.Attribute(), walker, map[string]bool{u.ID(): true}) + return walk(u.Attribute(), walker, map[expr.UserType]struct{}{u.Origin(): {}}) } // WalkMappedAttr iterates over the mapped attributes. It calls the given @@ -35,15 +37,16 @@ func WalkMappedAttr(ma *expr.MappedAttributeExpr, it MappedAttributeWalker) erro // Recursive implementation of the Walk methods. Takes care of avoiding infinite // recursions by keeping track of types that have already been walked. -func walk(at *expr.AttributeExpr, walker func(*expr.AttributeExpr) error, seen map[string]bool) error { +func walk(at *expr.AttributeExpr, walker func(*expr.AttributeExpr) error, seen map[expr.UserType]struct{}) error { if err := walker(at); err != nil { return err } walkUt := func(ut expr.UserType) error { - if _, ok := seen[ut.ID()]; ok { + origin := ut.Origin() + if _, ok := seen[origin]; ok { return nil } - seen[ut.ID()] = true + seen[origin] = struct{}{} return walk(ut.Attribute(), walker, seen) } switch actual := at.Type.(type) { @@ -71,7 +74,7 @@ func walk(at *expr.AttributeExpr, walker func(*expr.AttributeExpr) error, seen m case *expr.UserTypeExpr: return walkUt(actual) case *expr.ResultTypeExpr: - return walkUt(actual.UserTypeExpr) + return walkUt(actual) default: panic("unknown attribute type") // bug } diff --git a/codegen/walk_test.go b/codegen/walk_test.go new file mode 100644 index 0000000000..e71e66ff6b --- /dev/null +++ b/codegen/walk_test.go @@ -0,0 +1,88 @@ +// This file verifies that attribute traversal distinguishes unrelated design +// declarations while terminating when a declaration refers to itself. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +func TestWalkDistinguishesEqualUIDOrigins(t *testing.T) { + firstLeaf := &expr.AttributeExpr{Type: expr.String} + secondLeaf := &expr.AttributeExpr{Type: expr.Int} + first := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: firstLeaf}, + }}, + TypeName: "First", + UID: "shared", + } + second := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "second", Attribute: secondLeaf}, + }}, + TypeName: "Second", + UID: "shared", + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + + visited := make(map[*expr.AttributeExpr]bool) + require.NoError(t, Walk(root, func(att *expr.AttributeExpr) error { + visited[att] = true + return nil + })) + require.True(t, visited[firstLeaf]) + require.True(t, visited[secondLeaf]) +} + +func TestWalkPreservesDynamicResultTypeOrigin(t *testing.T) { + baseLeaf := &expr.AttributeExpr{Type: expr.String} + base := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "base", Attribute: baseLeaf}, + }}, + TypeName: "Base", + } + resultLeaf := &expr.AttributeExpr{Type: expr.Int} + embedded := base.Dup(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "result", Attribute: resultLeaf}, + }}).(*expr.UserTypeExpr) + result := &expr.ResultTypeExpr{ + UserTypeExpr: embedded, + Identifier: "application/vnd.result", + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "base", Attribute: &expr.AttributeExpr{Type: base}}, + {Name: "result", Attribute: &expr.AttributeExpr{Type: result}}, + }} + + visited := make(map[*expr.AttributeExpr]bool) + require.NoError(t, Walk(root, func(att *expr.AttributeExpr) error { + visited[att] = true + return nil + })) + require.True(t, visited[baseLeaf]) + require.True(t, visited[resultLeaf]) +} + +func TestWalkTypeTerminatesRecursiveCopy(t *testing.T) { + recursive := &expr.UserTypeExpr{TypeName: "Recursive", UID: "recursive"} + object := &expr.Object{} + recursive.AttributeExpr = &expr.AttributeExpr{Type: object} + self := &expr.AttributeExpr{Type: recursive} + object.Set("self", self) + copy := expr.Dup(recursive).(expr.UserType) + + visits := 0 + require.NoError(t, WalkType(copy, func(*expr.AttributeExpr) error { + visits++ + return nil + })) + require.Equal(t, 2, visits) +} diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md new file mode 100644 index 0000000000..0dddaa1abf --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -0,0 +1,844 @@ +# Generated Package Ownership Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every generated package-level declaration and every reference use one name collected once, frozen once, and retained through rendering. + +**Architecture:** One generation run instantiates fresh core and plugin objects, permits root mutation only during preparation, and builds one typed `generator.Plan`. Service, HTTP, JSON-RPC, gRPC, OpenAPI, example, and goa-ai plans collect package-owned `NameDeclaration` records, freeze them, link those records once into complete immutable render models, and render without rebuilding analysis or allocating a name. + +**Tech Stack:** Go 1.25, Goa evaluation and code generation, Protocol Buffers and `protoc-gen-go`, goa-ai plugins, `testify/require` + +**Spec:** `codegen/ARCHITECTURE.md` + +## Global Constraints + +- Never edit generated output; regenerate it from the owning design. +- Preparation is the last phase allowed to mutate an expression root. +- One run owns one immutable root snapshot, one `codegen.Generation`, and one typed `generator.Plan`. +- Every package-level type, function, constant, and variable has a package-owned `NameDeclaration` before freeze. +- Exact symbols reject normalized collisions; preferred symbols receive deterministic suffixes from stable typed ordering. +- `NameDeclaration.Name()` panics before freeze and is stable after freeze. +- Linking resolves retained facts through frozen declarations exactly once; it cannot collect another declaration or import. +- Render accepts retained typed plans. It does not accept roots, a generated module path, or callbacks that reconstruct analysis. +- Complete import path is the only import identity. Different package identities that normalize to one output import path or directory are rejected. +- Recursion uses `UserType.Origin()` only for cycle detection. Emitted declarations use complete typed declaration identities. +- Keep `expr.Union.Hash()` unchanged. +- Protoc-generated Go names come from one explicit, versioned naming contract that covers complete declaration families. +- Plugins consume the exact core service plan. Do not add `PlanKey`, plan registries, generic plan bags, reconstruction, decorated string keys, or process-global run state. +- `SectionTemplate.Name` is diagnostic metadata, not declaration identity. +- Never add fallbacks or compatibility modes. Migrate all in-tree callers and delete replaced APIs. +- Every exported construct needs GoDoc; every non-trivial file needs a concrete purpose and invariant header. + +--- + +## Completed foundation + +The following tasks are complete against their reviewed contracts. Their tests, +typed identities, import-path ownership, and transport correctness remain +required. The retained-plan audit supersedes their transitional callback and +reconstruction APIs; “complete” here records delivered history, not approval to +preserve those APIs. + +### Task 1: Executable failure contracts — complete + +**Commits:** `0103f6ab`, `dac45fe7` + +- [x] Added a real two-service generated-module test with relocated nested unions and HTTP/gRPC compilation. +- [x] Added exact relocated-name collision coverage. +- [x] Added the same-label file-section regression later resolved by Task 6. + +### Task 2: Generation-owned type catalog — complete + +**Commits:** `15f86ce7`, `7353f34b`, `8bda1ae4` + +- [x] Added one package catalog per generated import path. +- [x] Reserved exact user declarations before deterministic union allocation. +- [x] Froze package scopes and rejected planning through render-only accessors. + +### Task 3: Typed union identity and initial lifecycle — complete + +**Commits:** `4957ddef`, `a120d8ea` + +- [x] Added `UnionTypeID` without changing expression hash semantics. +- [x] Established prepare, plan, freeze, and render ordering. +- [x] Proved one generation reaches core and plugin callbacks. + +The retained-plan audit replaces `Genfunc`, callback plugin instances, and +render-time reconstruction introduced during this transition. + +### Task 4: Package-owned service declarations — complete + +**Commits:** `81b8bc37`, `4ca1f70c`, `5469ad1e`, `14b1a3c4`, `1bf62f51`, `839791a6` + +- [x] Added typed authored origin, derived method/view identities, complete union families, full-path import aliases, and cross-root package emission. +- [x] Made declaration records immutable and deterministic. +- [x] Bound service and views references to frozen package records. +- [x] Rejected unregistered roots and immutable-generation mutation. + +The retained-plan audit expands this ownership from selected type families to +every service package-level symbol and replaces `Plan` plus +`NewServicesData` re-analysis with one retained `service.Plan`. + +### Task 5: Transport declaration ownership — complete + +**Commits:** `595f50ad`, `8dc8ea8e`, `c6a0e1e0`, `393e9102`, `ddfc0472` + +- [x] Routed HTTP, gRPC, and JSON-RPC service references through exact frozen service declarations. +- [x] Added HTTP/JSON-RPC and protobuf wire catalogs, independent transform ownership, native gRPC metadata, generated import planning, and effective inherited-error validation. +- [x] Distinguished cycle identity from transport declaration identity and compiled the integrated generated modules. + +The retained-plan audit keeps these semantics and converts the render-time +catalog construction into retained HTTP, JSON-RPC, protobuf, and gRPC plans. + +--- + +## Remaining implementation + +### Task 6: Common declarations and fresh run lifecycle + +**Files:** +- Modify: `codegen/generation.go` +- Modify: `codegen/generated_types.go` +- Modify: `codegen/generated_types_test.go` +- Modify: `codegen/import_aliases.go` +- Modify: `codegen/normalize.go` +- Delete: `codegen/plugin.go` +- Delete: `codegen/plugin_test.go` +- Modify: `codegen/generator/generate.go` +- Replace: `codegen/generator/generators.go` +- Create: `codegen/generator/plan.go` +- Create: `codegen/generator/plugin.go` +- Create: `codegen/generator/plugin_test.go` +- Modify: `codegen/generator/generation_test.go` +- Modify: `codegen/generator/purity_test.go` +- Modify: `codegen/generator/generate_merge_test.go` +- Modify: `codegen/generator/service_union_package_scope_test.go` +- Modify: `codegen/generator/generate_http_union_shape_integration_test.go` +- Modify: `codegen/generator/generate_union_merge_integration_test.go` +- Modify: `codegen/walk.go` +- Modify: `codegen/import.go` +- Modify: `codegen/validation.go` +- Modify: `codegen/example/plan.go` +- Modify: `codegen/example/example_client.go` +- Modify: `codegen/example/example_server.go` + +**Interfaces:** +- Produces: package-owned `NameDeclaration` for type/function/constant/variable symbols +- Produces: `generator.Plugin`, `PluginFactory`, fresh core factories, and private-field `generator.Plan` +- Preserves: `Generation`, import-path bindings, `TypeDeclaration`, `UnionDeclaration`, and typed declaration identities + +- [x] **Step 1: Add declaration and lifecycle RED tests** + +Add table-driven tests proving one package namespace catches cross-kind +collisions, exact names reject, preferred names suffix in stable typed order, +`Name()` panics before freeze, and every existing type/union record returns its +contained canonical name record. Add canonical output-path tests where two +different package identities normalize to one import path or directory. + +Add repeated and concurrent generator tests. Register a factory whose plugin +keeps per-run counters, run generation twice and in parallel, and prove each run +starts at zero and receives only its own roots, plan, and files. Attempt root +mutation after preparation and require rejection or a purity failure at the +owning boundary. + +Run: + +```bash +go test ./codegen ./codegen/generator \ + -run 'TestNameDeclaration|TestGeneratedOutputPath|TestPluginFactory|TestConcurrentGeneration|TestPreparedRoots' \ + -count=1 +``` + +Expected: FAIL because names are still type-family-specific, plugins are +registered as callback instances in `codegen`, and `Generators` is mutable +process-global run state. + +- [x] **Step 2: Implement the common declaration owner** + +Add private preferred/final state and a package-level symbol kind to +`NameDeclaration`. Make exact and preferred declaration APIs return the same +record on idempotent typed identity and reject one identity binding to two +records. Allocate exact records first and preferred records in stable typed +order during `Generation.Freeze`. + +Embed or reference `NameDeclaration` from existing type, union, union branch, +imported toolchain, and later subsystem records. Remove duplicate name fields +as each owner migrates. Canonicalize output paths during collection and reject +different package owners that converge after normalization. + +- [x] **Step 3: Move orchestration and plugin registration into generator** + +Implement the approved public surface: + +```go +type Plugin struct { + Prepare PrepareFunc + Plan func(*Plan) error + Generate func(*Plan, []*codegen.File) ([]*codegen.File, error) +} + +type PluginFactory func() Plugin + +func RegisterPlugin(name, command string, factory PluginFactory) +func RegisterPluginFirst(name, command string, factory PluginFactory) +func RegisterPluginLast(name, command string, factory PluginFactory) + +func (p *Plan) Generation() *codegen.Generation +``` + +Store immutable factory descriptors and instantiate fresh plugins and core +generators before each run. Make `Generation` construction the final +preparation operation: normalize raw method objects there, snapshot the design +immediately afterward, and reject every later mutation. Delete `Genfunc`, the public +replaceable `Generators` variable, `renderOnly`, and the callback registry in +`codegen/plugin.go`. Tests install an isolated registry or command factory +through a private test seam, not a mutable production global. + +- [x] **Step 4: Finish mechanical identity and example cleanup** + +Audit every cycle-only walk and key it by `UserType.Origin()`. Keep semantic +`ID()` only where it identifies a named user type or a public semantic +identifier. Give every generated example a kind-tagged identity derived from +its exact owning expression: user type, method payload/result/error, HTTP +request/success/error body, object member, array element, map key/value, or +union branch. Reject unanchored draws and remove delimiter-joined paths, +caller-supplied response ordinals, and shared sequential collection streams. +Give independently mapped HTTP and JSON-RPC body types distinct stable semantic +IDs derived from their exact typed body owners so the recursive example cache +cannot return one transport's body for the other. Preserve authored type IDs +and expression hash behavior. +Remove render-time example scopes that own package-level names; leave local +argument and field scopes local. Add focused cross-kind, delimiter, response +reordering, dual-transport order, repeated-analysis, and concurrent-run +counterexamples. + +- [x] **Step 5: Verify and commit Task 6** + +Run: + +```bash +go fmt ./... +go test ./codegen ./codegen/generator -count=1 +go test ./... -run '^$' +git diff --check +``` + +All commands pass. +Commit the common owner and fresh-run lifecycle together because retained plans +depend on both contracts. + +### Task 7: Retained service plan and complete core symbols + +**Files:** +- Replace: `codegen/service/generated_package.go` +- Replace: `codegen/service/service_data.go` +- Modify: `codegen/service/service.go` +- Modify: `codegen/service/client.go` +- Modify: `codegen/service/endpoint.go` +- Modify: `codegen/service/views.go` +- Modify: `codegen/service/convert.go` +- Modify: `codegen/validation.go` +- Modify: `codegen/service/example_svc.go` +- Modify: `codegen/service/declaration_resolver.go` +- Modify: service headers/import builders that emit package symbols +- Modify: `codegen/generator/plan.go` +- Modify: `codegen/generator/service.go` +- Test: `codegen/service/*_test.go` +- Test: `codegen/generator/service_union_package_scope_test.go` + +**Interfaces:** +- Consumes: Task 6 `Generation`, `NameDeclaration`, and prepared root snapshot +- Produces: `service.NewPlans(generation, inputs...) ([]*service.Plan, error)` +- Produces: `service.NewPlan(root, generation, examples) (*service.Plan, error)` +- Produces: `generator.Plan.Service(root) *service.Plan` +- Produces: one post-freeze `service.Plan.Link()` operation before rendering +- Produces: service render functions that accept retained plans only + +- [x] **Step 1: Inventory and test every service package-level symbol** + +Build a table from templates and render data covering service and views types, +method wrappers, union families, endpoint constructors, clients, +errors, validators, conversions, interceptors, stream interfaces and helpers, +view constructors, and package variables. For each family, add a collision +fixture against a type and another generated function or constant. Assert the +declaration and every call site share the same `NameDeclaration` pointer, then +compile the generated service and views packages. + +Run: + +```bash +go test ./codegen/service ./codegen/generator \ + -run 'TestServicePlan|TestServicePackageDeclarations|TestRelocatedUnionPackageNamesCompile' \ + -count=1 +``` + +Expected: FAIL where `NewServicesData` and private render scopes still allocate +package-level endpoint, constructor, validator, conversion, or stream names. + +- [x] **Step 2: Build and retain the complete service-plan batch** + +Replace the declaration-only `service.Plan` function and render-time +`NewServicesData` reconstruction with one run-wide constructor: + +```go +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) +``` + +The constructor requires every Goa root owned by the generation exactly once. +It collects every root-local design fact, package import, output owner, and +package-level declaration, then assigns shared conversion methods and relocated +files across the complete run without reading provisional names. Structurally +equivalent compiler copies may share one declaration; candidates with different +retained Go layouts or union branch facts are rejected before freeze. Exact +duplicate external conversions across roots are rejected rather than receiving +an artificial numeric suffix. `NewPlan` remains only as the strict single-root +form and rejects a multi-root generation. After Generation freezes, `Plan.Link` +resolves the frozen records into the immutable render model without another +declaration traversal. `generator.Plan` stores the exact result by root and +returns it through `Service(root)`; unknown roots fail fast. + +- [x] **Step 3: Render only the retained plan** + +Change service, views, client, endpoint, conversion, validation, interceptor, +and starter implementation renderers to accept `*service.Plan` or typed values +owned by that plan. Remove root, generation, generated module path, and mutable +scope parameters that permit re-analysis or redirected output. Keep lexical +scopes only for locals, parameters, fields, and methods. + +Delete `NewServicesData`, the old `ServicesData` reconstruction constructor, +duplicate planning traversals, and any record that carries a second final name. + +- [x] **Step 4: Prove aggregation, order independence, and purity** + +Generate two roots contributing to one relocated package, reverse root and +service traversal, and assert byte-identical declarations. Mutate the source +service, method, type-location, field, validation, and conversion expressions +after planning, then assert core service output is byte-identical wherever the +core service plan owns those facts. Preserve distinct transport validation as +a valid counterexample: HTTP and gRPC own those validation programs when the +shared service declaration's Go layout is unchanged. Render twice without new +catalog entries and compile service, views, and example implementation packages. + +- [x] **Step 5: Verify and commit Task 7** + +Run: + +```bash +go fmt ./... +go test ./expr ./dsl ./codegen ./codegen/service ./codegen/generator -count=1 +go test ./... -run '^$' +git diff --check +``` + +All commands must pass. + +### Task 8: Retained HTTP and JSON-RPC plans + +**Files:** +- Replace: `http/codegen/plan.go` +- Replace: `http/codegen/service_data.go` +- Replace: `http/codegen/wire_catalog.go` +- Modify: `http/codegen/client.go` +- Modify: `http/codegen/server.go` +- Modify: `http/codegen/websocket.go` +- Modify: `http/codegen/sse.go` +- Modify: `http/codegen/sse_client.go` +- Modify: `http/codegen/types.go` +- Modify: `http/codegen/client_cli.go` +- Modify: `http/codegen/example_cli.go` +- Modify: `http/codegen/example_server.go` +- Replace: `jsonrpc/codegen/plan.go` +- Modify: `jsonrpc/codegen/client.go` +- Modify: `jsonrpc/codegen/server.go` +- Modify: `jsonrpc/codegen/websocket_client.go` +- Modify: `jsonrpc/codegen/websocket_server.go` +- Modify: `jsonrpc/codegen/example_server.go` +- Modify: `codegen/generator/plan.go` +- Modify: `codegen/generator/transport.go` +- Test: `http/codegen/plan_test.go` +- Test: `http/codegen/wire_catalog_test.go` +- Test: `http/codegen/service_data_purity_test.go` +- Test: `http/codegen/streaming_test.go` +- Test: `jsonrpc/codegen/plan_test.go` +- Test: `jsonrpc/codegen/kitchen_sink_test.go` +- Test: `jsonrpc/codegen/sse_integration_test.go` +- Test: `codegen/generator/service_union_package_scope_test.go` + +**Interfaces:** +- Consumes: exact retained `*service.Plan` +- Produces: typed retained HTTP and JSON-RPC plans with complete package declarations +- Preserves: independent wire/service transform ownership and detached HTTP bodies + +**Progress ledger (2026-08-21):** + +- Task 7 now reserves service-view imports only in the HTTP, JSON-RPC, and gRPC + files whose rendered sections reference them. The focused generated-module + proof covers viewed and ordinary services, unary and streaming gRPC, HTTP + SSE and WebSocket, and JSON-RPC unary code. The complete variable-view + transport behavior remains Task 8 work rather than an import-planning + exception. +- JSON-RPC unary responses currently discard the selected view: the server + always renders the first retained response-body variant and sends no view, + while the client tries to read `goa-view` from the HTTP response header. + Task 8 must make the selected representation explicit and reconstruct the + same view-specific body on both sides. +- JSON-RPC SSE and WebSocket clients currently decode `params` or `result` + bytes directly into the service result. This is not a valid shortcut. For + example, the generated Feed response body maps the wire property + `event_id` to `EventID`, but the service result has no JSON tag; direct + decoding silently leaves `EventID` unset. Task 8 must decode the retained + transport body, run its generated constructor and validation, then return + the canonical service result. + +- [x] **Step 1: Add complete HTTP/JSON-RPC declaration REDs** + +Inventory request, response, WebSocket, SSE, error, union, constructor, +validator, codec, stream, client, server, CLI, and example package symbols. +Create collisions between wire types and validators/constructors, between +request and response policy for one origin, and between HTTP and JSON-RPC +sections sharing an output package. Require stable names under reversed +endpoint order and compile the full generated module. + +Add two-view streaming-result contracts for HTTP SSE, JSON-RPC SSE, and +JSON-RPC WebSocket. Prove each method/request stream implements `SetView`, +retains its own view, projects through the canonical service constructor, and +selects the response-body declaration for that exact view. Use two concurrent +requests on one JSON-RPC WebSocket connection as the counterexample: a view +stored on the connection is invalid because one request may select `summary` +while another selects `detailed`. + +Cover the direct JSON-RPC `StreamHandler` API separately. For a method whose +view is not fixed by the design, each `SendNotification` and +`SendResponse` call must carry its own view; it must not inherit a +connection-global or latest value. Fixed-view methods remain specialized and +do not expose a redundant selector. Add client runtime proofs with nested and +transport-mapped fields so SSE and WebSocket receivers cannot pass by decoding +view-specific wire JSON directly into the service result. Include a required +snake-case field such as `event_id`: decoding it into a service field named +`EventID` must fail the test unless the generated transport-body constructor +performs the mapping. + +- [x] **Step 2: Build retained HTTP plans from exact service plans** + +Make HTTP `NewPlan` consume the prepared root's HTTP expressions and exact +`*service.Plan`. Collect detached client and server wire models, union families, +validators, helpers, imports, and file membership once. Move every current +`NewServicesData` and `wire_catalog` allocation into this constructor. Store +canonical service and wire declaration records in transform data. + +Retain one method/request-scoped view value for variable-view SSE and +WebSocket streams. The stream's `SetView` updates that value; send operations +use it to select both the service projection and the already-retained +view-specific response body. Never place mutable view selection on a shared +connection. + +- [x] **Step 3: Make JSON-RPC retain the HTTP plan it shares** + +Build one typed JSON-RPC plan that points at the exact HTTP plan used for HTTP +codecs and body files, then collects JSON-RPC-only declarations. Do not invoke +HTTP planning or analysis again. Make JSON-RPC render functions accept this +plan and delete their root/service reconstruction paths. + +Define one explicit viewed-stream wire contract shared by JSON-RPC SSE and +WebSocket. Every viewed streamed message carries the selected view together +with its view-specific body. Generated clients must select the matching +retained body decoder, reconstruct the projected value, validate the viewed +result, and return the canonical service result. Do not decode a projected +wire body directly into the service result, infer a default for a variable-view +method, or ask callers to construct generated views-package values. + +Apply the same representation contract to unary JSON-RPC. A variable-view +success response must carry the selected view with its view-specific body; +the server cannot choose the first body variant and the client cannot recover +the view from an unset HTTP header. Fixed-view unary methods remain fully +specialized and need no runtime discriminator. + +- [x] **Step 4: Remove context-dependent helper naming** + +Validators, constructors, conversions, stream helpers, and codecs must read +their `NameDeclaration`; call-site traversal selects a record but cannot name +it. Keep local field and variable scopes. Prove request/response and +WebSocket/SSE transforms enter service and wire owners independently. + +- [x] **Step 5: Verify and commit Task 8** + +Run: + +```bash +go fmt ./... +go test ./codegen/service ./http/codegen/... ./jsonrpc/codegen/... ./codegen/generator -count=1 +go test ./... -run '^$' +git diff --check +``` + +All commands must pass. + +### Task 9: Versioned protobuf descriptor plan and retained gRPC plan + +**Files:** +- Replace: `grpc/codegen/plan.go` +- Replace: `grpc/codegen/service_data.go` +- Replace: `grpc/codegen/protobuf_catalog.go` +- Modify: `grpc/codegen/protobuf.go` +- Modify: `grpc/codegen/proto.go` +- Modify: `grpc/codegen/proto_hooks.go` +- Modify: `grpc/codegen/types.go` +- Modify: `grpc/codegen/client.go` +- Modify: `grpc/codegen/server.go` +- Modify: `grpc/codegen/client_cli.go` +- Modify: `grpc/codegen/example_cli.go` +- Modify: `grpc/codegen/example_server.go` +- Create: `grpc/codegen/protoc_names.go` +- Create: `grpc/codegen/protoc_names_test.go` +- Modify: `codegen/generator/plan.go` +- Modify: `codegen/generator/transport.go` +- Test: `grpc/codegen/plan_test.go` +- Test: `grpc/codegen/proto_test.go` +- Test: `grpc/codegen/protobuf_test.go` +- Test: `grpc/codegen/protobuf_transform_test.go` +- Test: `grpc/codegen/service_data_traversal_test.go` +- Test: `grpc/codegen/service_metadata_reference_test.go` +- Test: `grpc/codegen/streaming_test.go` +- Test: `codegen/generator/service_union_package_scope_test.go` + +**Interfaces:** +- Consumes: exact retained `*service.Plan` +- Produces: retained protobuf descriptor plans and retained gRPC plans +- Produces: one explicit supported protoc/protoc-gen-go naming version and complete Go declaration families + +- [ ] **Step 1: Capture the real protoc naming contract as RED tests** + +Create descriptor fixtures for acronym and digit names, reserved words, nested +messages, enums, oneofs, services, streams, and explicit preferred names. Run +the supported real `protoc` and `protoc-gen-go` toolchain in a temporary module, +then compare every Goa-predicted package-level symbol with generated Go source. +Include message, enum/value, oneof interface/wrapper, client/server, and support +families. Add a test that rejects an unknown naming-version selector. + +Run: + +```bash +go test ./grpc/codegen -run 'TestProtocNameVersion|TestProtocDeclarationFamilies' -count=1 +``` + +Expected: FAIL because protoc naming is currently approximated across helpers +and the catalog does not retain complete versioned families. + +- [ ] **Step 2: Build one retained descriptor plan per protobuf package** + +Represent `.proto` declarations and protoc-generated Go declarations as +separate typed records. Give each family canonical `NameDeclaration` records +for every Go symbol Goa references. Identity includes complete emitted schema, +ordered fields/oneofs, field numbers, validation, defaults, source provenance, +and role where these facts change output; explicit protobuf names remain +preferences. + +Put the supported toolchain naming algorithm behind one explicit versioned +implementation. Delete scattered protoc CamelCase, oneof-wrapper, and service +name reconstruction after their callers consume family records. + +- [ ] **Step 3: Build and render one retained gRPC plan** + +Make gRPC `NewPlan` consume the exact `*service.Plan` and retained protobuf +descriptor plans. Collect messages, validators, conversions, native metadata, +streams, clients, servers, CLI, examples, imports, and output files before +freeze. Render `.proto` and Go files from the same records. + +- [ ] **Step 4: Make validators and transforms context-independent** + +Store the exact message, wrapper, validator, and conversion declarations in +render data. A transform context may select source and target records but may +not calculate their names. Add equal semantic ID/different origin cases, +same-origin/different role cases, reversed endpoint order, and one type reused +across unary and streaming roles. Compile and round-trip native metadata. + +- [ ] **Step 5: Verify and commit Task 9** + +Run: + +```bash +go fmt ./... +go test ./codegen/service ./grpc/codegen/... ./codegen/generator -count=1 +go test ./... -run '^$' +git diff --check +``` + +All commands must pass. + +### Task 10: OpenAPI, examples, and selective lifecycle integration + +**Files:** +- Modify: `codegen/generator/plan.go` +- Replace: `codegen/generator/openapi.go` +- Replace: `codegen/generator/example.go` +- Modify: `codegen/example/plan.go` +- Modify: `codegen/example/example_client.go` +- Modify: `codegen/example/example_server.go` +- Modify: `http/codegen/openapi.go` +- Modify: `http/codegen/openapi/v2/builder.go` +- Modify: `http/codegen/openapi/v2/files.go` +- Modify: `http/codegen/openapi/v2/openapi.go` +- Modify: `http/codegen/openapi/v3/builder.go` +- Modify: `http/codegen/openapi/v3/example.go` +- Modify: `http/codegen/openapi/v3/files.go` +- Modify: `http/codegen/openapi/v3/openapi.go` +- Modify: `codegen/generator/generation_test.go` +- Modify: `codegen/generator/purity_test.go` + +**Interfaces:** +- Consumes: retained service and selected transport plans +- Produces: retained OpenAPI and example plans +- Produces: one core command plan with no render-time root or generation reconstruction + +- [x] **Step 1: Add selective-command and plan-identity REDs** + +For `gen`, `example`, and focused test commands, assert each selected subsystem +is planned once, each renderer receives the exact retained pointer, unselected +subsystems allocate nothing, and the prepared root remains unchanged after the +plan boundary. Cover OpenAPI-only semantic example IDs separately from Go +declaration identity. + +- [x] **Step 2: Retain OpenAPI and example analysis** + +Build typed OpenAPI plans from prepared expressions and typed example plans +from exact service/transport plans. The example plan owns its server +composition data; delete the process-global `codegen/example.Servers` map. +Retain one private JSON Schema registry per OpenAPI plan; delete the exported +mutable `Definitions` map and process-global definition-name state. The +returned specification owns its definition map and schema values, so a later +build cannot mutate it. Use the typed example identities established in Task +6. Collect every example and CLI package-level constructor, variable, and +helper through the owning package catalog. + +- [x] **Step 3: Make the core plan the only command execution model** + +Have command factories construct one private-field `generator.Plan` containing +the exact selected subsystem plans. Core render dispatch reads those fields; +it does not call `NewPlan`, `NewServicesData`, `Generation.Roots`, or accept a +second generated module path. Remove all remaining generator adapters and +callback-shaped lifecycle tests. + +- [x] **Step 4: Prove purity, selection, repeated runs, and compilation** + +Run each command twice and concurrently with different roots. Assert byte- +identical output per input, no cross-run state, no late declarations, and no +unselected files. Build disjoint example servers and OpenAPI specifications +sequentially and behind a start barrier; assert no server or schema from one +design appears in the other and the first returned result remains unchanged +after the second build. Run both concurrency tests with the race detector. +Compile full HTTP/gRPC/JSON-RPC examples and validate both OpenAPI versions. + +- [x] **Step 5: Verify and commit Task 10** + +Run: + +```bash +go fmt ./... +go test ./codegen/... ./http/codegen/... ./grpc/codegen/... ./jsonrpc/codegen/... \ + -skip '^TestMergeFilesPreservesSameLabelSections$' -count=1 +go test ./... -run '^$' +git diff --check +``` + +The skipped merge regression remains Task 12 work. + +### Task 11: Goa-ai retained plans and plugin migration + +**Files:** +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/init.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/data.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/generate.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/generate_toolset_specs.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/generate_agent_files.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_build.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_helpers.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_materialize.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_misc.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_type_info.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_types.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_unions.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/ir/build.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/mcp/init.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/mcp/generate.go` +- Modify: `/Users/raphael/src/goa-ai/eval/codegen/codegen.go` +- Test: `/Users/raphael/src/goa-ai/codegen/agent/generate_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_internal_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/agent/uniontest/union_names_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/agent/tests/golden_deep_nested_validations_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/mcp/contract_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/mcp/state_test.go` +- Test: `/Users/raphael/src/goa-ai/eval/codegen/codegen_test.go` + +**Interfaces:** +- Consumes: generator plugin factories and exact `generator.Plan.Service(root)` +- Produces: retained agent specification, MCP, and eval plans +- Removes: temporary-root rendering, repeated service/spec reconstruction, shared spec scopes, and string union companion keys + +- [ ] **Step 1: Add plugin and specification REDs** + +Add a repeated/concurrent run test in one process, an MCP service whose package +overlaps a core service package, and an AURA-shaped large tool-spec package +with colliding validator/constructor preferences. Assert public tool specs and +HTTP transport specs own independent package plans and natural names. Assert +MCP render consumes the exact core service-plan pointer created after prepare. + +- [ ] **Step 2: Attach all generated expressions during prepare** + +Register fresh agent, MCP, and eval plugin factories. MCP prepare creates and +validates its service/types/JSON-RPC expressions and attaches them to a +canonical registered root before normalization and core planning. Do not create +a render-time temporary root or a second core service plan. + +- [ ] **Step 3: Build one retained goa-ai plan per output package** + +Build agent IR and tool specification data once during plugin planning. Split +public-spec and transport-spec package owners. Retain typed declaration records +for every generated type, validator, constructor, tool variable, and union +family. Delete repeated `NewServicesData`/IR/spec builders, +`UnionTypeHash`-based companion keys, shared `NameScope` use across packages, +and any emitted-name reconstruction. + +- [ ] **Step 4: Render through exact core and plugin plans** + +Agent, MCP, and eval render callbacks accept `*generator.Plan` and their +factory-owned retained plugin plan. MCP consumes `Plan.Service(root)` and emits +only plugin-owned adapters or modifications; core service/JSON-RPC plans emit +the attached service declarations once. No plugin accesses a plan registry or +looks up a “latest” analysis. + +- [ ] **Step 5: Verify and commit Goa-ai** + +Use a disposable module replacement or the repository's established local Goa +development workflow without committing an unrelated replacement. Run: + +```bash +go fmt ./... +go test ./codegen/... ./eval/codegen/... -count=1 +go test ./... -run '^$' +git diff --check +``` + +Generate and compile the AURA-shaped goa-ai fixture. Record the Goa commit it +requires, commit the goa-ai changes separately, and open or update the goa-ai +pull request. + +### Task 12: Lossless merge, full regeneration, review, and publication + +**Files:** +- Modify: `codegen/generator/generate.go` +- Modify: `codegen/generator/generate_merge_test.go` +- Regenerate: `/Users/raphael/src/aura/gen` only through AURA generation scripts +- Update: Goa and goa-ai pull request descriptions + +**Interfaces:** +- Consumes: complete retained plans and package-owned declaration deduplication +- Produces: lossless same-path assembly and fully verified Goa, goa-ai, and AURA branches + +- [x] **Step 1: Make same-path file assembly lossless** + +Merge compatible headers and imports, then append every non-header section in +producer order. Never deduplicate by `SectionTemplate.Name`. Require all +same-path contributors to name the same canonical package identity; package +planning already owns declaration reuse and collision rejection. + +Run: + +```bash +go test ./codegen/generator -run TestMergeFilesPreservesSameLabelSections -count=1 +``` + +This was completed with Task 6 because lossless file assembly is part of the +common run lifecycle. Both same-label bodies are preserved, all contributor +finalizers run in order, and incompatible headers or output paths fail before +rendering. + +- [ ] **Step 2: Delete every superseded mechanism** + +Require these production searches to return no hits: + +```bash +rg -n 'Genfunc|renderOnly|NewServicesData|NewServicesDataForRoots|PlanKey|UnionTypeHash|unionRegistryKey|unionCompanionKey|userTypePkgs' \ + --glob '*.go' +rg -n 'var Generators|codegen\.RegisterPlugin|RunPluginsPlan|RunPluginsPrepare' \ + --glob '*.go' +``` + +Inspect every surviving `NewNameScope` in service, transport, example, and +goa-ai code. Retain it only when it owns lexical local names; no package-level +declaration or import may depend on it. Search every render function for root, +generated module path, and `Generation` inputs and remove remaining analysis +or output redirection. + +- [ ] **Step 3: Verify Goa completely** + +Run: + +```bash +go fmt ./... +go test ./... -count=1 +make lint +git diff --check +``` + +Expected: all pass with no skipped regression. + +- [ ] **Step 4: Verify goa-ai completely** + +Run in `/Users/raphael/src/goa-ai` against the final local Goa commit: + +```bash +go fmt ./... +go test ./... -count=1 +git diff --check +``` + +Expected: all pass. + +- [ ] **Step 5: Regenerate and verify AURA from scratch** + +Run in `/Users/raphael/src/aura`: + +```bash +./scripts/gen goa +./scripts/gen +cd gen && go test ./... -count=1 +``` + +Do not patch anything under `gen/`. Each generation command deletes and +recreates its owned output. Review the generated diff for unexpected public +name changes, then run the relevant AURA service/eval suites identified by +`docs/TROUBLESHOOT.md` and the original production-task reproduction. + +- [ ] **Step 6: Run independent whole-branch reviews** + +Review Goa and goa-ai against `codegen/ARCHITECTURE.md`. Require explicit +checks for every package-level symbol, exact/preferred collision policy, +output-path normalization, retained plan identity, protoc family/version +accuracy, repeated/concurrent runs, plugin root ownership, dead APIs, and +lossless merging. Fix every confirmed finding and repeat full verification. + +- [ ] **Step 7: Publish clear pull requests** + +Update the Goa PR in plain language: describe the invalid AURA validation +function reference, why separate analyses disagreed, the one-plan/one-name +rule, breaking plugin API, protoc proof, generated-source effects, and exact +verification commands. Update or create the goa-ai PR with its prepare-time MCP +attachment and retained spec-plan changes. Address every applicable GitHub +Copilot review comment before merge, push only verified commits, and keep both +PRs draft until dependent verification is green. + +## Final completion proof + +The work is complete only when all of these statements are true: + +- one fresh factory instance owns each core generator and plugin in each run; +- roots never change after preparation; +- one typed core plan retains exact typed subsystem plans; +- every emitted package-level symbol has one frozen `NameDeclaration`; +- every declaration and reference consumes that same record; +- no renderer reconstructs service, wire, protobuf, OpenAPI, example, or plugin analysis; +- protobuf Go names match the explicit supported real toolchain family; +- repeated and concurrent runs are isolated; +- same-path file contributions are lossless; +- Goa, goa-ai, and freshly regenerated AURA all compile and pass their tests; and +- independent review finds no registry, fallback, compatibility path, duplicate owner, or dead transitional API. diff --git a/dsl/api.go b/dsl/api.go index 9a47d14414..6dd9c9084a 100644 --- a/dsl/api.go +++ b/dsl/api.go @@ -1,3 +1,5 @@ +// This file defines the API-level DSL, including immutable configuration for +// the example streams created separately by each generation run. package dsl import ( @@ -157,8 +159,9 @@ func License(fn func()) { // // Randomizer must appear in an API expression. // -// Randomizer takes a single argument which is an implementation of -// expr.Randomizer. +// Randomizer takes a single argument which is an immutable +// expr.RandomizerFactory. The factory creates a fresh value stream for each +// code generation run. // // The default randomizer uses the API name as the seed, to get consistent // random examples. @@ -166,7 +169,7 @@ func License(fn func()) { // Example: // // var _ = API("divider", func() { -// Randomizer(expr.NewFakerRandomizer("different seed")) +// Randomizer(expr.NewFakerRandomizerFactory("different seed")) // }) // // There's also a deterministic randomizer which will only generate one example @@ -175,11 +178,15 @@ func License(fn func()) { // Example: // // var _ = API("divider", func() { -// Randomizer(expr.NewDeterministicRandomizer()) +// Randomizer(expr.NewDeterministicRandomizerFactory()) // }) -func Randomizer(randomizer expr.Randomizer) { +func Randomizer(factory expr.RandomizerFactory) { if s, ok := eval.Current().(*expr.APIExpr); ok { - s.ExampleGenerator = &expr.ExampleGenerator{Randomizer: randomizer} + if factory == nil { + eval.ReportError("Randomizer requires a non-nil randomizer factory") + return + } + s.RandomizerFactory = factory return } eval.IncompatibleDSL() diff --git a/dsl/attribute.go b/dsl/attribute.go index 0b8b21787b..9a7dcfea62 100644 --- a/dsl/attribute.go +++ b/dsl/attribute.go @@ -138,7 +138,7 @@ func Attribute(name string, args ...any) { var attr *expr.AttributeExpr { if ref := parent.Find(name); ref != nil { - attr = expr.DupAtt(ref) + attr = expr.DupAttForDSL(ref) } dataType, description, fn := parseAttributeArgs(attr, args...) @@ -170,7 +170,7 @@ func Attribute(name string, args ...any) { } union := parent.Type.(*expr.Union) if _, ok := attr.Type.(expr.UserType); !ok { - att := expr.DupAtt(attr) + att := expr.DupAttForDSL(attr) attr.Type = &expr.UserTypeExpr{AttributeExpr: att, TypeName: union.TypeName + expr.Title(name)} } union.Values = append(union.Values, &expr.NamedAttributeExpr{Name: name, Attribute: attr}) diff --git a/dsl/error.go b/dsl/error.go index 35bad61525..203ce6f8c0 100644 --- a/dsl/error.go +++ b/dsl/error.go @@ -1,3 +1,6 @@ +// This file defines service and method error DSL functions. Error declarations +// select service value contracts; transport mappings separately choose how +// those values are encoded by HTTP or gRPC. package dsl import ( @@ -62,6 +65,14 @@ const ( // the service methods) or Method expressions. Error may also appear under the API // expression to create reusable error definitions. // +// A reusable API or service transport response mapping is matched to a method +// error by name, but it does not replace the method's error type. If a method or +// service shadows the reusable error with the same name, both error attributes +// must define the same effective type, validations, defaults, and struct +// metadata after Reference and Extend inheritance is applied. Goa compares a +// detached finalized copy and rejects incompatible definitions during design +// validation without changing the authored declarations. +// // See Attribute for details on the Error arguments. // // Example: diff --git a/dsl/grpc.go b/dsl/grpc.go index e494b4458d..4f8c3f25bc 100644 --- a/dsl/grpc.go +++ b/dsl/grpc.go @@ -1,3 +1,5 @@ +// This file defines the gRPC transport DSL for endpoint messages, metadata, +// status responses, streaming behavior, and protobuf field mappings. package dsl import ( @@ -251,7 +253,10 @@ func Message(fn func()) { // typed stream frame rather than being rewritten into metadata. // // Metadata takes one argument of function type which lists the attributes -// that must be set in the request metadata instead of the message. +// that must be set in the request metadata instead of the message. Each +// selected attribute must have an effective primitive type or an array whose +// elements have an effective primitive type. Named aliases are accepted and +// converted to the native metadata value by generated client and server code. // If Metadata is set in the gRPC endpoint expression, it inherits the // attribute properties (description, type, meta, validations etc.) from the // method payload. @@ -302,6 +307,9 @@ func Metadata(fn func()) { // // Trailers takes one argument of function type which lists the attributes // that must be set in the trailer response metadata instead of the message. +// Each selected attribute must have an effective primitive type or an array +// whose elements have an effective primitive type. Named aliases are accepted +// and converted to the native metadata value by generated code. // If Trailers is set in the gRPC response expression, it inherits the // attribute properties (description, type, meta, validations etc.) from the // method result. diff --git a/dsl/headers.go b/dsl/headers.go index 8828cd6586..2f351cbdab 100644 --- a/dsl/headers.go +++ b/dsl/headers.go @@ -1,3 +1,5 @@ +// This file defines HTTP request and response header DSL and the shared entry +// point used to select gRPC response metadata fields. package dsl import ( @@ -9,7 +11,9 @@ import ( // When used in a HTTP expression, it groups a set of Header expressions and // makes it possible to list required headers using the Required function. // When used in a GRPC response expression, it defines the headers to be sent -// in the response metadata. +// in the response metadata. A gRPC response header must have an effective +// primitive type or an array whose elements have an effective primitive type; +// generated codecs convert named service aliases to and from native values. // // To define HTTP headers, Headers must appear in an Service HTTP expression // to define request headers common to all the service methods. Headers may diff --git a/dsl/http.go b/dsl/http.go index 40fd5c90f1..14ac5df967 100644 --- a/dsl/http.go +++ b/dsl/http.go @@ -783,6 +783,8 @@ func MapParams(args ...any) { // MIME multipart encoding as defined in RFC 2046. // // MultipartRequest must appear in a HTTP endpoint expression. +// At least one payload value must remain in the request body after path, +// query, header, and cookie mappings are applied. // // goa generates a custom encoder that writes the payload for requests made to // HTTP endpoints that use MultipartRequest. The generated encoder accept a @@ -790,11 +792,11 @@ func MapParams(args ...any) { // multipart content. The user provided function accepts a multipart writer // and a reference to the payload and is responsible for encoding the payload. // goa also generates a custom decoder that reads back the multipart content -// into the payload struct. The generated decoder also accepts a user provided -// function that takes a multipart reader and a reference to the payload struct -// as parameter. The user provided decoder is responsible for decoding the -// multipart content into the payload. The example command generates a default -// implementation for the user decoder and encoder. +// into the generated HTTP request body. The generated decoder accepts a user +// provided function that takes a multipart reader and a reference to that body +// as parameters. Goa validates the decoded body before it builds the service +// payload. The example command generates a default implementation for the user +// decoder and encoder. func MultipartRequest() { e, ok := eval.Current().(*expr.HTTPEndpointExpr) if !ok { @@ -979,7 +981,7 @@ func Body(args ...any) { eval.ReportError("%s type does not have an attribute named %#v", kind, a) return } - attr = expr.DupAtt(attr) + attr = expr.DupAttForDSL(attr) attr.AddMeta("origin:attribute", a) if rt, ok := attr.Type.(*expr.ResultTypeExpr); ok && expr.IsArray(rt.Type) { // If the attribute type is a result type collection add the type to the diff --git a/dsl/jsonrpc.go b/dsl/jsonrpc.go index c6d7240cb1..1bad1cc60f 100644 --- a/dsl/jsonrpc.go +++ b/dsl/jsonrpc.go @@ -27,7 +27,7 @@ const ( // JSONRPC configures a service to use JSON-RPC 2.0 transport. // The generated code handles JSON-RPC protocol details: request parsing, method dispatch, // response formatting, and batch processing. All service JSON-RPC methods share -// a single HTTP endpoint and must use the same transport (HTTP, WebSocket or SSE). +// a single HTTP POST endpoint. Methods may stream results over Server-Sent Events. // // JSONRPC can be used at three levels: // @@ -61,32 +61,15 @@ const ( // notifications), and marshal the responses into a single array of JSON-RPC // response objects in the HTTP response body. // -// WebSocket: -// -// For WebSocket transport, methods that use StreamingPayload() and/or StreamingResult() -// enable bidirectional streaming: each payload or result element is sent as a separate, -// complete JSON-RPC message over the WebSocket connection. When using WebSockets, all -// methods must use StreamingPayload() for their payload (if any) and StreamingResult() -// for their result (if any), because a single WebSocket connection is shared by all -// methods of a service and client. Non-streaming methods are not supported over WebSockets. -// -// WebSocket methods can have three patterns: -// - StreamingPayload() only: Client-to-server notifications (no response) -// - StreamingResult() only: Server-to-client notifications (no request ID, sent without client request) -// - Both StreamingPayload() and StreamingResult(): Bidirectional request/response streaming -// -// Server-side notifications (methods with StreamingResult() but no StreamingPayload()) are -// sent from the server to the client without an associated request ID, as they are not -// responses to client requests but rather server-initiated messages. -// // Server-Sent Events: // -// For Server-Sent Events (SSE), enable SSE by calling the ServerSentEvents() function -// within the JSONRPC expression. In this mode, each element of the result is sent as a -// separate JSON-RPC response within its own SSE event. The SSE id field is mapped to -// the result's ID attribute. Because all methods for a given service and client -// share the same HTTP endpoint, every method must use both StreamingResult() and -// ServerSentEvents() to ensure correct streaming behavior. +// A JSON-RPC method may stream results by defining StreamingResult() and calling +// ServerSentEvents() in its method-level JSONRPC expression. The client sends one +// JSON-RPC request. Each streamed value is sent as a complete JSON-RPC message in +// a separate SSE event. The SSE id field may be mapped to a result attribute. +// JSON-RPC does not support StreamingPayload(), bidirectional streaming, or one +// method that defines both Result() and StreamingResult(). Use +// separate methods when clients need both a stream and a final resource. // // Using JSON-RPC with Other Transports: // @@ -94,15 +77,6 @@ const ( // For example, a method can have both standard HTTP or gRPC endpoints in addition // to a JSON-RPC endpoint. // -// Important WebSocket Limitation: -// -// A service cannot mix JSON-RPC WebSocket endpoints with pure HTTP WebSocket endpoints. -// This is because JSON-RPC WebSocket uses a single underlying WebSocket connection -// for all methods in the service, with method dispatch happening at the protocol level -// through JSON-RPC message routing. In contrast, pure HTTP WebSocket creates individual -// connections per streaming endpoint. These two approaches are fundamentally incompatible -// and cannot coexist in the same service. -// // Error Codes: // // Use the predefined constants for standard JSON-RPC errors: @@ -112,7 +86,7 @@ const ( // - RPCInvalidParams (-32602): Invalid method parameters // - RPCInternalError (-32603): Internal JSON-RPC error (default for unmapped errors) // -// Example - Complete service with request/notification handling and streaming: +// Example - Service with request and notification handling: // // Service("calc", func() { // Error("timeout", ErrTimeout, "Request timed out") // Define an error that all service methods can return @@ -144,44 +118,6 @@ const ( // }) // }) // -// Example - WebSocket streaming service: -// -// Service("chat", func() { -// JSONRPC(func() { -// GET("/ws") // Use GET for WebSocket endpoint -// }) -// Method("send", func() { -// StreamingPayload(func() { -// Attribute("message", String, "Message to send") -// }) -// JSONRPC(func() { -// // Client-to-server notification (no response) -// }) -// }) -// Method("notify", func() { -// StreamingResult(func() { -// Attribute("event", String, "Server notification") -// Attribute("data", Any, "Notification data") -// }) -// JSONRPC(func() { -// // Server-to-client notification (no request ID, server-initiated) -// }) -// }) -// Method("echo", func() { -// StreamingPayload(func() { -// ID("req_id", String, "Request ID") -// Attribute("message", String, "Message to echo") -// }) -// StreamingResult(func() { -// ID("req_id", String, "Request ID") -// Attribute("echo", String, "Echoed message") -// }) -// JSONRPC(func() { -// // Bidirectional request/response streaming -// }) -// }) -// }) -// // Example - SSE streaming service: // // Service("updater", func() { @@ -198,7 +134,7 @@ const ( // Attribute("data", Data, "Event data") // }) // JSONRPC(func() { -// ServerSentEvents(func() { // Use SSE instead of WebSocket +// ServerSentEvents(func() { // Stream results as server-sent events // SSERequestID("last_event_id") // Map SSE Last-Event-ID header to payload "last_event_id" attribute // SSEEventID("id") // Use "id" result attribute as SSE event ID // }) diff --git a/dsl/meta.go b/dsl/meta.go index 750f1b56ad..c20adae516 100644 --- a/dsl/meta.go +++ b/dsl/meta.go @@ -157,10 +157,13 @@ const DefaultProtoc = expr.DefaultProtoc // that command. The given command will have additional arguments appended and // is expected to behave similar to protoc. // -// Can be used to specify custom options or alternate implementations. The -// default command can be specified using DefaultProtoc. +// Goa always uses protoc-gen-go v1.36.12 and protoc-gen-go-grpc v1.6.2. It +// finds and checks both programs before code generation starts, then passes +// their absolute paths to the chosen compiler. A protoc:cmd value may add +// other compiler options, but it cannot use --plugin to replace either of +// these programs. The default compiler can be specified using DefaultProtoc. // -// // Use Go run to run a drop-in replacement for protoc. +// // Use Go run to run another compiler that accepts protoc arguments. // var _ = API("myapi", func() { // Meta("protoc:cmd", "go", "run", "github.com/duckbrain/goprotoc") // }) diff --git a/dsl/payload.go b/dsl/payload.go index 2e96fd8a37..8fc5abc3f4 100644 --- a/dsl/payload.go +++ b/dsl/payload.go @@ -88,9 +88,9 @@ func Payload(val any, args ...any) { // // The arguments to a StreamingPayload DSL is same as the Payload DSL. // -// StreamingPayload requires a transport that supports client-to-server streaming -// such as gRPC or WebSockets. When using HTTP or JSON-RPC transports, methods -// with StreamingPayload must use WebSockets (via GET endpoints). +// StreamingPayload requires a transport that supports client-to-server +// streaming. gRPC supports it directly. Ordinary HTTP methods use a WebSocket +// through a GET endpoint. JSON-RPC methods do not support StreamingPayload. // For gRPC methods that define both Payload and StreamingPayload, the ordinary // method payload is sent once as the initial typed stream frame and the // StreamingPayload values are sent as subsequent stream item frames. @@ -175,7 +175,7 @@ func methodDSL(m *expr.MethodExpr, suffix string, p any, args ...any) *expr.Attr // Do not duplicate type if it is not customized return &expr.AttributeExpr{Type: actual} } - dupped := expr.Dup(actual) + dupped := expr.DupForDSL(actual) att = &expr.AttributeExpr{Type: dupped} if f, ok := args[len(args)-1].(func()); ok { numreqs := 0 diff --git a/dsl/randomizer_test.go b/dsl/randomizer_test.go new file mode 100644 index 0000000000..e425183d88 --- /dev/null +++ b/dsl/randomizer_test.go @@ -0,0 +1,37 @@ +// This file verifies that the API DSL stores immutable example factory +// configuration instead of a mutable random stream. +package dsl + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestRandomizerStoresFactory(t *testing.T) { + factory := expr.NewDeterministicRandomizerFactory() + api := expr.NewAPIExpr("test", func() {}) + eval.Context = &eval.DSLContext{} + + eval.Execute(func() { + Randomizer(factory) + }, api) + + require.Empty(t, eval.Context.Errors) + require.Equal(t, factory, api.RandomizerFactory) +} + +func TestRandomizerRejectsNilFactory(t *testing.T) { + api := expr.NewAPIExpr("test", func() {}) + eval.Context = &eval.DSLContext{} + + eval.Execute(func() { + Randomizer(nil) + }, api) + + require.Len(t, eval.Context.Errors, 1) + require.Contains(t, eval.Context.Errors[0].Error(), "non-nil randomizer factory") +} diff --git a/dsl/result_type.go b/dsl/result_type.go index bb82848587..a796df713f 100644 --- a/dsl/result_type.go +++ b/dsl/result_type.go @@ -529,7 +529,7 @@ func buildView(name string, mt *expr.ResultTypeExpr, at *expr.AttributeExpr) (*e n := nat.Name cat := nat.Attribute if existing := mt.Find(n); existing != nil { - dup := expr.DupAtt(existing) + dup := expr.DupAttForDSL(existing) if v, ok := cat.Meta.Last(expr.ViewMetaKey); ok { dup.AddMeta("view", v) } diff --git a/expr/api.go b/expr/api.go index 98afdcc4ed..b67dcd1149 100644 --- a/expr/api.go +++ b/expr/api.go @@ -1,3 +1,5 @@ +// This file defines the evaluated API expression and the immutable example +// randomizer configuration shared by independent code generation runs. package expr import ( @@ -47,8 +49,9 @@ type ( // JSONRPC contains the JSON-RPC specific API level expressions. JSONRPC *JSONRPCExpr - // random generator used to build examples for the API types. - ExampleGenerator *ExampleGenerator + // RandomizerFactory is the immutable configuration used to create a + // fresh example value stream for each code generation run. + RandomizerFactory RandomizerFactory } // ContactExpr contains the API contact information. @@ -116,12 +119,12 @@ type ( // NewAPIExpr initializes an API expression. func NewAPIExpr(name string, dsl func()) *APIExpr { return &APIExpr{ - Name: name, - HTTP: new(HTTPExpr), - GRPC: new(GRPCExpr), - JSONRPC: new(JSONRPCExpr), - DSLFunc: dsl, - ExampleGenerator: NewRandom(name), + Name: name, + HTTP: new(HTTPExpr), + GRPC: new(GRPCExpr), + JSONRPC: new(JSONRPCExpr), + DSLFunc: dsl, + RandomizerFactory: NewFakerRandomizerFactory(name), } } diff --git a/expr/attached_service.go b/expr/attached_service.go new file mode 100644 index 0000000000..d0a16390c9 --- /dev/null +++ b/expr/attached_service.go @@ -0,0 +1,239 @@ +// This file checks and finishes services that generators add after the design +// DSL has run. +package expr + +import ( + "fmt" + "slices" + + "goa.design/goa/v3/eval" +) + +// EvaluateAttachedServices prepares, checks, and finishes services that a +// generator added to r. The services and types must already belong to r. No +// service is finished unless every added expression is valid. +func (r *RootExpr) EvaluateAttachedServices(services []*ServiceExpr, types ...UserType) error { + sets, err := r.attachedServiceExpressions(services, types) + if err != nil { + return err + } + prepareExpressions(sets) + if err := validateExpressions(r, sets); err != nil { + return err + } + finalizeExpressions(sets) + return nil +} + +// attachedServiceExpressions verifies that each service and type belongs to r +// and that every endpoint points to a method on its service. It returns the +// expressions in the order that Prepare, Validate, and Finalize must run. +func (r *RootExpr) attachedServiceExpressions( + services []*ServiceExpr, + types []UserType, +) ([]eval.ExpressionSet, error) { + selected := make(map[*ServiceExpr]struct{}, len(services)) + methods := make(eval.ExpressionSet, 0) + for _, service := range services { + if !slices.Contains(r.Services, service) { + return nil, fmt.Errorf("service %q is not part of this design", service.Name) + } + if r.Service(service.Name) != service { + return nil, fmt.Errorf("service name %q is already used in this design", service.Name) + } + if _, ok := selected[service]; ok { + return nil, fmt.Errorf("service %q was provided more than once", service.Name) + } + selected[service] = struct{}{} + for _, method := range service.Methods { + if method.Service != service { + return nil, fmt.Errorf("method %q belongs to a different service", method.Name) + } + methods = append(methods, method) + } + } + + typeExpressions := make(eval.ExpressionSet, len(types)) + for index, userType := range types { + if !slices.Contains(r.Types, userType) { + return nil, fmt.Errorf("type %q is not part of this design", userType.Name()) + } + typeExpressions[index] = userType.Attribute() + } + + var ( + httpServices, httpEndpoints, httpFileServers eval.ExpressionSet + jsonrpcServices, jsonrpcEndpoints, jsonrpcFileServers eval.ExpressionSet + grpcServices, grpcEndpoints eval.ExpressionSet + err error + ) + if r.API.HTTP != nil { + httpServices, httpEndpoints, httpFileServers, err = collectHTTPExpressions( + r.API.HTTP.Services, + r.API.HTTP, + selected, + ) + if err != nil { + return nil, err + } + } + if r.API.JSONRPC != nil { + jsonrpcServices, jsonrpcEndpoints, jsonrpcFileServers, err = collectHTTPExpressions( + r.API.JSONRPC.Services, + &r.API.JSONRPC.HTTPExpr, + selected, + ) + if err != nil { + return nil, err + } + } + if r.API.GRPC != nil { + grpcServices, grpcEndpoints, err = collectGRPCExpressions(r.API.GRPC.Services, selected) + if err != nil { + return nil, err + } + } + + for service := range selected { + service.design = r + } + return []eval.ExpressionSet{ + typeExpressions, + eval.ToExpressionSet(services), + methods, + httpServices, + httpEndpoints, + httpFileServers, + jsonrpcServices, + jsonrpcEndpoints, + jsonrpcFileServers, + grpcServices, + grpcEndpoints, + }, nil +} + +// collectHTTPExpressions returns the selected HTTP services, endpoints, and +// file servers. It rejects a child that points to another service. +func collectHTTPExpressions( + transports []*HTTPServiceExpr, + httpRoot *HTTPExpr, + selected map[*ServiceExpr]struct{}, +) (eval.ExpressionSet, eval.ExpressionSet, eval.ExpressionSet, error) { + services := make(eval.ExpressionSet, 0) + endpoints := make(eval.ExpressionSet, 0) + fileServers := make(eval.ExpressionSet, 0) + for _, transport := range transports { + if _, ok := selected[transport.ServiceExpr]; !ok { + continue + } + if transport.Root != httpRoot { + return nil, nil, nil, fmt.Errorf("HTTP service %q uses a different design", transport.Name()) + } + services = append(services, transport) + for _, endpoint := range transport.HTTPEndpoints { + if endpoint.Service != transport { + return nil, nil, nil, fmt.Errorf( + "HTTP endpoint %q belongs to a different HTTP service", + endpoint.Name(), + ) + } + if !slices.Contains(transport.ServiceExpr.Methods, endpoint.MethodExpr) { + return nil, nil, nil, fmt.Errorf( + "HTTP endpoint %q uses a method outside service %q", + endpoint.Name(), + transport.Name(), + ) + } + endpoints = append(endpoints, endpoint) + } + for _, fileServer := range transport.FileServers { + if fileServer.Service != transport { + return nil, nil, nil, fmt.Errorf( + "HTTP file server %q belongs to a different HTTP service", + fileServer.FilePath, + ) + } + fileServers = append(fileServers, fileServer) + } + } + return services, endpoints, fileServers, nil +} + +// collectGRPCExpressions returns the selected gRPC services and endpoints and +// rejects any endpoint that points outside its service. +func collectGRPCExpressions( + transports []*GRPCServiceExpr, + selected map[*ServiceExpr]struct{}, +) (eval.ExpressionSet, eval.ExpressionSet, error) { + services := make(eval.ExpressionSet, 0) + endpoints := make(eval.ExpressionSet, 0) + for _, transport := range transports { + if _, ok := selected[transport.ServiceExpr]; !ok { + continue + } + services = append(services, transport) + for _, endpoint := range transport.GRPCEndpoints { + if endpoint.Service != transport { + return nil, nil, fmt.Errorf( + "gRPC endpoint %q belongs to a different gRPC service", + endpoint.Name(), + ) + } + if !slices.Contains(transport.ServiceExpr.Methods, endpoint.MethodExpr) { + return nil, nil, fmt.Errorf( + "gRPC endpoint %q uses a method outside service %q", + endpoint.Name(), + transport.Name(), + ) + } + endpoints = append(endpoints, endpoint) + } + } + return services, endpoints, nil +} + +// prepareExpressions calls Prepare on each added expression in Goa's required +// order. +func prepareExpressions(sets []eval.ExpressionSet) { + for _, set := range sets { + for _, expression := range set { + if preparer, ok := expression.(eval.Preparer); ok { + preparer.Prepare() + } + } + } +} + +// validateExpressions checks the complete design and each added expression. It +// returns all errors together. +func validateExpressions(root *RootExpr, sets []eval.ExpressionSet) error { + errors := new(eval.ValidationErrors) + if err := root.Validate(); err != nil { + errors.AddError(root, err) + } + for _, set := range sets { + for _, expression := range set { + if validator, ok := expression.(eval.Validator); ok { + if err := validator.Validate(); err != nil { + errors.AddError(expression, err) + } + } + } + } + if len(errors.Errors) > 0 { + return errors + } + return nil +} + +// finalizeExpressions calls Finalize on each added expression after every +// check succeeds. +func finalizeExpressions(sets []eval.ExpressionSet) { + for _, set := range sets { + for _, expression := range set { + if finalizer, ok := expression.(eval.Finalizer); ok { + finalizer.Finalize() + } + } + } +} diff --git a/expr/attached_service_test.go b/expr/attached_service_test.go new file mode 100644 index 0000000000..6abeaccf36 --- /dev/null +++ b/expr/attached_service_test.go @@ -0,0 +1,224 @@ +// This file checks services that generators add after the design DSL has +// finished running. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEvaluateAttachedServicesUsesOwningRoot(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + other := newRootExprForTest("other_header") + Root = other + + service, _, endpoint := attachTestService(owner, "generated") + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) + + headers := AsObject(endpoint.Headers.Type) + require.NotNil(t, headers.Attribute("owner_header")) + require.Nil(t, headers.Attribute("other_header")) +} + +func TestEvaluateAttachedServicesDoesNotReadPackageRoot(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + service, _, endpoint := attachTestService(owner, "generated") + Root = nil + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) + require.NotNil(t, AsObject(endpoint.Headers.Type).Attribute("owner_header")) +} + +func TestEvaluateAttachedGRPCServiceUsesOwningRootForAPIErrors(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + service := attachTestGRPCServiceWithAPIError(owner, "generated") + Root = nil + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) +} + +func TestEvaluateAttachedGRPCServiceIgnoresPackageRootAPIErrors(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + service := attachTestGRPCServiceWithAPIError(owner, "generated") + other := newRootExprForTest("other_header") + other.Errors = append(other.Errors, &ErrorExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + Name: "failed", + }) + Root = other + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) +} + +func TestEvaluateAttachedServiceFinishesFileServerWithOwningRoot(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + owner.API.HTTP.Path = "/owner" + service, transport, _ := attachTestService(owner, "generated") + transport.Paths = []string{"/generated"} + fileServer := &HTTPFileServerExpr{ + Service: transport, + FilePath: "./public", + RequestPaths: []string{"/assets/{*path}"}, + } + transport.FileServers = append(transport.FileServers, fileServer) + Root = nil + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) + require.Equal(t, []string{"/owner/generated/assets/{*path}"}, fileServer.RequestPaths) +} + +func TestEvaluateAttachedServiceIgnoresPackageRootForFileServer(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + owner.API.HTTP.Path = "/owner" + service, transport, _ := attachTestService(owner, "generated") + transport.Paths = []string{"/generated"} + fileServer := &HTTPFileServerExpr{ + Service: transport, + FilePath: "./public", + RequestPaths: []string{"/assets/{*path}"}, + } + transport.FileServers = append(transport.FileServers, fileServer) + other := newRootExprForTest("other_header") + other.API.HTTP.Path = "/other" + Root = other + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) + require.Equal(t, []string{"/owner/generated/assets/{*path}"}, fileServer.RequestPaths) +} + +func TestEvaluateAttachedServicesChecksAllBeforeFinishing(t *testing.T) { + root := newRootExprForTest("owner_header") + first, firstTransport, _ := attachTestService(root, "first") + second, _, _ := attachTestService(root, "second") + second.Methods[0].Payload.Validation = &ValidationExpr{Required: []string{"missing"}} + + err := root.EvaluateAttachedServices([]*ServiceExpr{first, second}) + + require.Error(t, err) + require.Empty(t, firstTransport.Paths) +} + +func TestEvaluateAttachedServicesRunsDifferentRootsTogether(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + firstRoot := newRootExprForTest("first_header") + firstService, _, firstEndpoint := attachTestService(firstRoot, "first") + secondRoot := newRootExprForTest("second_header") + secondService, _, secondEndpoint := attachTestService(secondRoot, "second") + Root = nil + + errors := make(chan error, 2) + go func() { + errors <- firstRoot.EvaluateAttachedServices([]*ServiceExpr{firstService}) + }() + go func() { + errors <- secondRoot.EvaluateAttachedServices([]*ServiceExpr{secondService}) + }() + require.NoError(t, <-errors) + require.NoError(t, <-errors) + + require.NotNil(t, AsObject(firstEndpoint.Headers.Type).Attribute("first_header")) + require.Nil(t, AsObject(firstEndpoint.Headers.Type).Attribute("second_header")) + require.NotNil(t, AsObject(secondEndpoint.Headers.Type).Attribute("second_header")) + require.Nil(t, AsObject(secondEndpoint.Headers.Type).Attribute("first_header")) +} + +// newRootExprForTest returns a design with one API header. +func newRootExprForTest(header string) *RootExpr { + api := NewAPIExpr("test", func() {}) + obj := &Object{} + obj.Set(header, &AttributeExpr{Type: String}) + api.HTTP.Headers = NewMappedAttributeExpr(&AttributeExpr{Type: obj}) + return &RootExpr{API: api} +} + +// attachTestService adds one HTTP service whose payload contains the API +// header used by the test. +func attachTestService(root *RootExpr, name string) (*ServiceExpr, *HTTPServiceExpr, *HTTPEndpointExpr) { + payload := &Object{} + for _, header := range *AsObject(root.API.HTTP.Headers.Type) { + payload.Set(header.Name, header.Attribute) + } + service := &ServiceExpr{Name: name} + method := &MethodExpr{ + Name: "run", + Payload: &AttributeExpr{Type: payload}, + Result: &AttributeExpr{Type: Empty}, + Service: service, + Stream: NoStreamKind, + } + service.Methods = []*MethodExpr{method} + root.Services = append(root.Services, service) + transport := root.API.HTTP.ServiceFor(service, root.API.HTTP) + endpoint := transport.EndpointFor(method) + endpoint.Routes = []*RouteExpr{{ + Method: "POST", + Path: "/run", + Endpoint: endpoint, + }} + return service, transport, endpoint +} + +// attachTestGRPCServiceWithAPIError adds one gRPC method that uses an error +// response defined for the complete API. +func attachTestGRPCServiceWithAPIError(root *RootExpr, name string) *ServiceExpr { + apiError := &ErrorExpr{ + AttributeExpr: &AttributeExpr{Type: ErrorResult}, + Name: "failed", + } + root.Errors = append(root.Errors, apiError) + response := &GRPCResponseExpr{ + StatusCode: 13, + Parent: root.API.GRPC, + } + response.Prepare() + root.API.GRPC.Errors = append(root.API.GRPC.Errors, &GRPCErrorExpr{ + Name: "failed", + Response: response, + }) + + service := &ServiceExpr{Name: name} + method := &MethodExpr{ + Name: "run", + Payload: &AttributeExpr{Type: Empty}, + Result: &AttributeExpr{Type: Empty}, + Errors: []*ErrorExpr{{ + AttributeExpr: &AttributeExpr{Type: ErrorResult}, + Name: "failed", + }}, + Service: service, + Stream: NoStreamKind, + } + service.Methods = []*MethodExpr{method} + root.Services = append(root.Services, service) + transport := root.API.GRPC.ServiceFor(service) + transport.EndpointFor(method.Name, method) + return service +} diff --git a/expr/attribute.go b/expr/attribute.go index 3a67e9b9c1..6f50575c00 100644 --- a/expr/attribute.go +++ b/expr/attribute.go @@ -1,3 +1,6 @@ +// This file defines Goa attributes and their validation rules. It also lets +// transport and generated-type copies point back to the exact attribute +// written in the evaluated design. package expr import ( @@ -32,9 +35,12 @@ type ( DefaultValue any // UserExample set in DSL or computed in Finalize UserExamples []*ExampleExpr - // finalized is true if the attribute has been finalized - only - // applies if attribute type is an object + // finalized reports whether bases and references have already been + // applied to this attribute. finalized bool + // authored points to the first attribute copied from the evaluated + // design. It is nil while this value is that original attribute. + authored *AttributeExpr } // ExampleExpr represents an example. @@ -109,6 +115,15 @@ type ( CookieSameSiteValue string ) +// AuthoredAttribute returns the first attribute from which a was copied. It +// returns a when a was written directly in the evaluated design. +func (a *AttributeExpr) AuthoredAttribute() *AttributeExpr { + if a.authored != nil { + return a.authored + } + return a +} + const ( // FormatDate describes RFC3339 date values. FormatDate ValidationFormat = "date" @@ -160,9 +175,6 @@ const ( CookieSameSiteDefault CookieSameSiteValue = "default" ) -// validated keeps track of validated attributes to handle cyclical definitions. -var validated = make(map[*AttributeExpr]bool) - // TaggedAttribute returns the name of the child attribute of a with the given // tag if a is an object. func TaggedAttribute(a *AttributeExpr, tag string) string { @@ -237,10 +249,20 @@ func (a *AttributeExpr) EvalName() string { // to be used in error messages. The parent definition context is automatically // added to error messages. func (a *AttributeExpr) Validate(ctx string, parent eval.Expression) *eval.ValidationErrors { - if validated[a] { + return a.validate(ctx, parent, make(map[*AttributeExpr]struct{})) +} + +// validate checks attributes reached from a. The map stops a type that refers +// to itself from being checked forever. +func (a *AttributeExpr) validate( + ctx string, + parent eval.Expression, + visited map[*AttributeExpr]struct{}, +) *eval.ValidationErrors { + if _, ok := visited[a]; ok { return nil } - validated[a] = true + visited[a] = struct{}{} verr := new(eval.ValidationErrors) if a.Type == nil { verr.Add(parent, "attribute type is nil") @@ -269,14 +291,14 @@ func (a *AttributeExpr) Validate(ctx string, parent eval.Expression) *eval.Valid for _, nat := range *o { verr.Merge(a.validatePkgPath(pkgPath, nat.Attribute.Type)) ctx = fmt.Sprintf("field %s", nat.Name) - verr.Merge(nat.Attribute.Validate(ctx, parent)) + verr.Merge(nat.Attribute.validate(ctx, parent, visited)) } } else if ar := AsArray(a.Type); ar != nil { elemType := ar.ElemType - verr.Merge(elemType.Validate(ctx, a)) + verr.Merge(elemType.validate(ctx, a, visited)) } else if u := AsUnion(a.Type); u != nil { for _, ut := range u.Values { - verr.Merge(ut.Attribute.Validate(ctx, parent)) + verr.Merge(ut.Attribute.validate(ctx, parent, visited)) } } diff --git a/expr/attribute_test.go b/expr/attribute_test.go index b8451687de..320998cb76 100644 --- a/expr/attribute_test.go +++ b/expr/attribute_test.go @@ -1129,3 +1129,79 @@ func TestAttributeExprValidationValidate(t *testing.T) { } } } + +func TestAttributeExprValidateChecksEachCall(t *testing.T) { + parent := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "Parent", + } + attribute := &AttributeExpr{ + Type: &Object{}, + Validation: &ValidationExpr{Required: []string{"missing"}}, + } + + first := attribute.Validate("payload", parent) + second := attribute.Validate("payload", parent) + + if first == nil { + t.Error("first check returned no errors, expected 1") + } else if len(first.Errors) != 1 { + t.Errorf("first check returned %d errors, expected 1", len(first.Errors)) + } + if second == nil { + t.Error("second check returned no errors, expected 1") + } else if len(second.Errors) != 1 { + t.Errorf("second check returned %d errors, expected 1", len(second.Errors)) + } +} + +func TestAttributeExprValidateChecksSharedTypeOncePerCall(t *testing.T) { + minLength, maxLength := 2, 1 + shared := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: &Object{ + &NamedAttributeExpr{ + Name: "value", + Attribute: &AttributeExpr{ + Type: String, + Validation: &ValidationExpr{ + MinLength: &minLength, + MaxLength: &maxLength, + }, + }, + }, + }}, + TypeName: "Shared", + } + attribute := &AttributeExpr{Type: &Object{ + &NamedAttributeExpr{ + Name: "first", + Attribute: &AttributeExpr{Type: shared}, + }, + &NamedAttributeExpr{ + Name: "second", + Attribute: &AttributeExpr{Type: shared}, + }, + }} + parent := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "Parent", + } + + checks := []*eval.ValidationErrors{ + attribute.Validate("payload", parent), + attribute.Validate("payload", parent), + } + for i, result := range checks { + if result == nil { + t.Errorf("check %d returned no errors, expected 1", i+1) + continue + } + if len(result.Errors) != 1 { + t.Errorf("check %d returned %d errors, expected 1", i+1, len(result.Errors)) + continue + } + if got, want := result.Errors[0].Error(), "field value - min length is greater than max length"; got != want { + t.Errorf("check %d returned %q, expected %q", i+1, got, want) + } + } +} diff --git a/expr/dup.go b/expr/dup.go index b658286526..ac361470d8 100644 --- a/expr/dup.go +++ b/expr/dup.go @@ -1,3 +1,5 @@ +// This file copies design types while preserving which original declaration +// each copied type came from. package expr import ( @@ -9,32 +11,65 @@ func Dup(d DataType) DataType { return newDupper().DupType(d) } -// DupAtt creates a copy of the given attribute. +// DupForDSL creates a copy of the given data type and registers copied result +// types so Goa evaluates their DSL. +func DupForDSL(dataType DataType) DataType { + dupper := newDSLDupper() + result := dupper.DupType(dataType) + dupper.registerResultTypes() + return result +} + +// DupAtt creates a copy of the given attribute without changing the design. func DupAtt(att *AttributeExpr) *AttributeExpr { - dupper := newDupper() - duppedBases := make([]DataType, len(att.Bases)) - for i, b := range att.Bases { - duppedBases[i] = dupper.DupType(b) - } - res := dupper.DupAttribute(att) - res.Bases = duppedBases - return res + return newDupper().DupAtt(att) +} + +// DupAttForDSL creates a copy of the given attribute and registers copied +// result types so Goa evaluates their DSL. +func DupAttForDSL(att *AttributeExpr) *AttributeExpr { + dupper := newDSLDupper() + result := dupper.DupAtt(att) + dupper.registerResultTypes() + return result } // dupper implements recursive and cycle safe copy of data types. type dupper struct { - uts map[string]UserType - ats map[*AttributeExpr]struct{} + uts map[UserType]UserType + ats map[*AttributeExpr]struct{} + registerTypes bool + resultTypeCopies []*ResultTypeExpr } -// newDupper returns a new initialized dupper. +// newDupper returns a copier that does not change the evaluated design. func newDupper() *dupper { return &dupper{ - uts: make(map[string]UserType), + uts: make(map[UserType]UserType), ats: make(map[*AttributeExpr]struct{}), } } +// newDSLDupper returns a copier that records generated result types whose DSL +// must run before evaluation is complete. +func newDSLDupper() *dupper { + dupper := newDupper() + dupper.registerTypes = true + return dupper +} + +// DupAtt creates a copy of att and its base attributes with one shared type +// map so repeated and recursive types remain shared in the copy. +func (d *dupper) DupAtt(att *AttributeExpr) *AttributeExpr { + duppedBases := make([]DataType, len(att.Bases)) + for i, b := range att.Bases { + duppedBases[i] = d.DupType(b) + } + res := d.DupAttribute(att) + res.Bases = duppedBases + return res +} + // DupAttribute creates a copy of the given attribute. func (d *dupper) DupAttribute(att *AttributeExpr) *AttributeExpr { if _, ok := d.ats[att]; ok { @@ -59,6 +94,7 @@ func (d *dupper) DupAttribute(att *AttributeExpr) *AttributeExpr { DSLFunc: att.DSLFunc, UserExamples: att.UserExamples, finalized: att.finalized, + authored: att.AuthoredAttribute(), } d.ats[&dup] = struct{}{} return &dup @@ -101,20 +137,20 @@ func (d *dupper) DupType(t DataType) DataType { } return &dp case UserType: - if u, ok := d.uts[actual.ID()]; ok { + origin := actual.Origin() + if u, ok := d.uts[origin]; ok { return u } dp := actual.Dup(nil) - d.uts[actual.ID()] = dp + d.uts[origin] = dp dupAtt := d.DupAttribute(actual.Attribute()) dp.SetAttribute(dupAtt) - // Make sure that if we are dupping a generated type we also put - // the dup in the generated type list so that it gets properly - // eval'd. - if rt, ok := dp.(*ResultTypeExpr); ok { + // DSL copies must be evaluated because their DSL may define views + // used by the attribute that contains the copy. + if rt, ok := dp.(*ResultTypeExpr); d.registerTypes && ok { if GeneratedResultType(rt.Identifier) != nil { - GeneratedResultTypes.Append(rt) + d.resultTypeCopies = append(d.resultTypeCopies, rt) } } @@ -122,3 +158,11 @@ func (d *dupper) DupType(t DataType) DataType { } panic("unknown type " + fmt.Sprintf("%T", t)) } + +// registerResultTypes adds every generated result type found in a DSL copy +// after the complete graph has been copied. +func (d *dupper) registerResultTypes() { + for _, resultType := range d.resultTypeCopies { + GeneratedResultTypes.Append(resultType) + } +} diff --git a/expr/dup_test.go b/expr/dup_test.go index f1295a20c8..752b3adbeb 100644 --- a/expr/dup_test.go +++ b/expr/dup_test.go @@ -21,3 +21,92 @@ func TestDupPreservesNonNullableArrayElements(t *testing.T) { assert.NotSame(t, original.ElemType, duplicate.ElemType) assert.True(t, duplicate.NonNullableElems) } + +// TestDupGeneratedResultTypes verifies that ordinary copies do not change the +// evaluated design and DSL copies register the result types whose DSL must run. +func TestDupGeneratedResultTypes(t *testing.T) { + ResetDSL(t) + + resultType := NewResultTypeExpr("Item", "application/vnd.item", func() {}) + GeneratedResultTypes.Append(resultType) + attribute := &AttributeExpr{Type: &Array{ElemType: &AttributeExpr{Type: resultType}}} + + duplicate := DupAtt(attribute) + require.Len(t, *GeneratedResultTypes, 1) + require.NotSame(t, resultType, duplicate.Type.(*Array).ElemType.Type) + + dslDuplicate := DupAttForDSL(attribute) + require.Len(t, *GeneratedResultTypes, 2) + require.Same(t, dslDuplicate.Type.(*Array).ElemType.Type, (*GeneratedResultTypes)[1]) +} + +func TestIsErrorResultRecognizesCopies(t *testing.T) { + duplicate := DupAtt(&AttributeExpr{Type: ErrorResult}).Type + + require.True(t, IsErrorResult(ErrorResult)) + require.True(t, IsErrorResult(duplicate)) + require.False(t, IsErrorResult(&UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "error", + })) + require.False(t, IsErrorResult(String)) +} + +func TestDupKeepsRootTypeAndSameNameUnionAliasDistinct(t *testing.T) { + rootType := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "ValueBool", + } + unionAlias := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: Boolean}, + TypeName: "ValueBool", + } + attribute := &AttributeExpr{Type: &Object{ + {Name: "root", Attribute: &AttributeExpr{Type: rootType}}, + {Name: "choice", Attribute: &AttributeExpr{Type: &Union{ + TypeName: "Value", + Values: []*NamedAttributeExpr{{ + Name: "bool", + Attribute: &AttributeExpr{Type: unionAlias}, + }}, + }}}, + }} + + duplicate := DupAtt(attribute) + object := duplicate.Type.(*Object) + rootCopy := object.Attribute("root").Type.(UserType) + union := object.Attribute("choice").Type.(*Union) + aliasCopy := union.Values[0].Attribute.Type.(UserType) + require.NotSame(t, rootCopy, aliasCopy) + require.Same(t, rootType, rootCopy.Origin()) + require.Same(t, unionAlias, aliasCopy.Origin()) + require.Equal(t, String, rootCopy.Attribute().Type) + require.Equal(t, Boolean, aliasCopy.Attribute().Type) +} + +// TestDupAttributeKeepsAuthoredAttribute verifies that every transport copy +// can find the exact attribute written in the design, including after more +// than one copy. +func TestDupAttributeKeepsAuthoredAttribute(t *testing.T) { + originalChild := &AttributeExpr{Type: String} + original := &AttributeExpr{Type: &Object{ + {Name: "child", Attribute: originalChild}, + }} + + first := DupAtt(original) + second := DupAtt(first) + + require.Same(t, original, first.AuthoredAttribute()) + require.Same(t, original, second.AuthoredAttribute()) + require.Same(t, originalChild, first.Type.(*Object).Attribute("child").AuthoredAttribute()) + require.Same(t, originalChild, second.Type.(*Object).Attribute("child").AuthoredAttribute()) +} + +func TestDupSchemeKeepsAuthoredScheme(t *testing.T) { + authored := &SchemeExpr{SchemeName: "key"} + first := DupScheme(authored) + second := DupScheme(first) + + require.Same(t, authored, first.AuthoredScheme()) + require.Same(t, authored, second.AuthoredScheme()) +} diff --git a/expr/error_contract.go b/expr/error_contract.go new file mode 100644 index 0000000000..ee6218c66a --- /dev/null +++ b/expr/error_contract.go @@ -0,0 +1,423 @@ +// This file builds the error definition that a method inherits from its +// service or API. HTTP and gRPC settings may change how the error is sent, but +// they do not change the service error value. +package expr + +import ( + "reflect" + "slices" +) + +type ( + // attributePair identifies two nodes already compared while traversing + // recursive error types. + attributePair struct { + first *AttributeExpr + second *AttributeExpr + } + + // effectiveErrorCopier copies an inherited error without changing the + // evaluated design. The maps reconnect recursive types to their copies. + effectiveErrorCopier struct { + attributes map[*AttributeExpr]*AttributeExpr + userTypes map[UserType]UserType + } +) + +// equivalentErrorAttributes reports whether two error attributes generate the +// same service value contract. Descriptions and examples are documentation; +// types, validations, defaults, and metadata affect generated code or runtime +// behavior and must match. +func equivalentErrorAttributes(first, second *AttributeExpr) bool { + if first == second { + return true + } + if first == nil || second == nil { + return false + } + first = effectiveErrorAttribute(first) + second = effectiveErrorAttribute(second) + return equivalentErrorAttributeNodes(first, second, make(map[attributePair]struct{})) +} + +// differingErrorQualifierSettings lists error settings that would change the +// generated service error returned to callers. +func differingErrorQualifierSettings(first, second *AttributeExpr) []string { + first = effectiveErrorAttribute(first) + second = effectiveErrorAttribute(second) + qualifiers := []struct { + name string + key string + }{ + {name: "temporary", key: "goa:error:temporary"}, + {name: "timeout", key: "goa:error:timeout"}, + {name: "fault", key: "goa:error:fault"}, + } + var different []string + for _, qualifier := range qualifiers { + _, firstSet := first.Meta[qualifier.key] + _, secondSet := second.Meta[qualifier.key] + if firstSet != secondSet { + different = append(different, qualifier.name) + } + } + return different +} + +// effectiveErrorAttribute returns a detached copy with References and Bases +// applied by AttributeExpr.Finalize. Validation can therefore compare the +// value contracts code generation will see without mutating evaluated design. +func effectiveErrorAttribute(source *AttributeExpr) *AttributeExpr { + copier := &effectiveErrorCopier{ + attributes: make(map[*AttributeExpr]*AttributeExpr), + userTypes: make(map[UserType]UserType), + } + result := copier.attribute(source) + result.Finalize() + return result +} + +// attribute copies one attribute shell before following its type and +// inheritance edges so self-recursive graphs terminate on the copied shell. +func (c *effectiveErrorCopier) attribute(source *AttributeExpr) *AttributeExpr { + if source == nil { + return nil + } + if copied, ok := c.attributes[source]; ok { + return copied + } + copied := &AttributeExpr{ + Description: source.Description, + DefaultValue: cloneErrorContractValue(source.DefaultValue), + DSLFunc: source.DSLFunc, + } + c.attributes[source] = copied + if source.Docs != nil { + docs := *source.Docs + copied.Docs = &docs + } + if source.Validation != nil { + copied.Validation = cloneErrorValidation(source.Validation) + } + if source.Meta != nil { + copied.Meta = source.Meta.Dup() + } + if len(source.UserExamples) > 0 { + copied.UserExamples = make([]*ExampleExpr, len(source.UserExamples)) + for index, example := range source.UserExamples { + copy := *example + copy.Value = cloneErrorContractValue(example.Value) + copied.UserExamples[index] = © + } + } + copied.Type = c.dataType(source.Type) + copied.Bases = c.dataTypes(source.Bases) + copied.References = c.dataTypes(source.References) + return copied +} + +// cloneErrorValidation detaches slices and scalar pointers that +// ValidationExpr.Dup deliberately shares with its source. +func cloneErrorValidation(source *ValidationExpr) *ValidationExpr { + copied := source.Dup() + copied.Values = make([]any, len(source.Values)) + for index, value := range source.Values { + copied.Values[index] = cloneErrorContractValue(value) + } + copied.ExclusiveMinimum = dupFloat(source.ExclusiveMinimum) + copied.Minimum = dupFloat(source.Minimum) + copied.Maximum = dupFloat(source.Maximum) + copied.ExclusiveMaximum = dupFloat(source.ExclusiveMaximum) + copied.MinLength = dupInt(source.MinLength) + copied.MaxLength = dupInt(source.MaxLength) + return copied +} + +// cloneErrorContractValue copies the collection values accepted by defaults, +// enum validations, and examples. Primitive values are immutable and can be +// shared safely. +func cloneErrorContractValue(source any) any { + switch actual := source.(type) { + case Val: + copied := make(Val, len(actual)) + for name, value := range actual { + copied[name] = cloneErrorContractValue(value) + } + return copied + case ArrayVal: + copied := make(ArrayVal, len(actual)) + for index, value := range actual { + copied[index] = cloneErrorContractValue(value) + } + return copied + case MapVal: + copied := make(MapVal, len(actual)) + for key, value := range actual { + copied[cloneErrorContractValue(key)] = cloneErrorContractValue(value) + } + return copied + case []any: + copied := make([]any, len(actual)) + for index, value := range actual { + copied[index] = cloneErrorContractValue(value) + } + return copied + case []byte: + return append([]byte(nil), actual...) + case map[string]any: + copied := make(map[string]any, len(actual)) + for name, value := range actual { + copied[name] = cloneErrorContractValue(value) + } + return copied + case map[any]any: + copied := make(map[any]any, len(actual)) + for key, value := range actual { + copied[cloneErrorContractValue(key)] = cloneErrorContractValue(value) + } + return copied + default: + return actual + } +} + +// dataTypes reconnects inheritance declarations to the same copied graph used +// by attribute types. +func (c *effectiveErrorCopier) dataTypes(source []DataType) []DataType { + if len(source) == 0 { + return nil + } + copied := make([]DataType, len(source)) + for index, dataType := range source { + copied[index] = c.dataType(dataType) + } + return copied +} + +// dataType copies each concrete type without registering generated result +// types. User-type shells are installed before their attributes are followed. +func (c *effectiveErrorCopier) dataType(source DataType) DataType { + switch actual := source.(type) { + case nil: + return nil + case Primitive: + return actual + case *Object: + copied := make(Object, 0, len(*actual)) + for _, field := range *actual { + copied = append(copied, &NamedAttributeExpr{ + Name: field.Name, + Attribute: c.attribute(field.Attribute), + }) + } + return &copied + case *Array: + return &Array{ + ElemType: c.attribute(actual.ElemType), + NonNullableElems: actual.NonNullableElems, + } + case *Map: + return &Map{ + KeyType: c.attribute(actual.KeyType), + ElemType: c.attribute(actual.ElemType), + } + case *Union: + copied := &Union{ + TypeName: actual.TypeName, + TypeKey: actual.TypeKey, + ValueKey: actual.ValueKey, + Values: make([]*NamedAttributeExpr, len(actual.Values)), + } + for index, branch := range actual.Values { + copied.Values[index] = &NamedAttributeExpr{ + Name: branch.Name, + Attribute: c.attribute(branch.Attribute), + } + } + return copied + case *ResultTypeExpr: + origin := actual.Origin() + if copied, ok := c.userTypes[origin]; ok { + return copied + } + copied := &ResultTypeExpr{ + UserTypeExpr: &UserTypeExpr{ + TypeName: actual.TypeName, + UID: actual.UID, + }, + Identifier: actual.Identifier, + ContentType: actual.ContentType, + } + c.userTypes[origin] = copied + copied.AttributeExpr = c.attribute(actual.AttributeExpr) + copied.Views = make([]*ViewExpr, len(actual.Views)) + for index, view := range actual.Views { + copied.Views[index] = &ViewExpr{ + AttributeExpr: c.attribute(view.AttributeExpr), + Name: view.Name, + Parent: copied, + } + } + return copied + case *UserTypeExpr: + origin := actual.Origin() + if copied, ok := c.userTypes[origin]; ok { + return copied + } + copied := &UserTypeExpr{ + TypeName: actual.TypeName, + UID: actual.UID, + } + c.userTypes[origin] = copied + copied.AttributeExpr = c.attribute(actual.AttributeExpr) + return copied + case UserType: + origin := actual.Origin() + if copied, ok := c.userTypes[origin]; ok { + return copied + } + copied := actual.Dup(nil) + c.userTypes[origin] = copied + copied.SetAttribute(c.attribute(actual.Attribute())) + return copied + default: + panic("unknown error attribute type") + } +} + +// equivalentErrorAttributeNodes compares every contract-bearing node while +// stopping when recursive user types revisit the same declaration pair. +func equivalentErrorAttributeNodes(first, second *AttributeExpr, seen map[attributePair]struct{}) bool { + if first == second { + return true + } + pair := attributePair{first: first, second: second} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if !equivalentErrorValidation(first.Validation, second.Validation) || + !reflect.DeepEqual(first.DefaultValue, second.DefaultValue) || + !equivalentErrorMetadata(first.Meta, second.Meta) { + return false + } + + switch firstType := first.Type.(type) { + case Primitive: + secondType, ok := second.Type.(Primitive) + return ok && firstType == secondType + case UserType: + secondType, ok := second.Type.(UserType) + return ok && + firstType.Name() == secondType.Name() && + equivalentErrorAttributeNodes(firstType.Attribute(), secondType.Attribute(), seen) + case *Object: + secondType, ok := second.Type.(*Object) + if !ok || len(*firstType) != len(*secondType) { + return false + } + for _, field := range *firstType { + other := secondType.Attribute(field.Name) + if other == nil || !equivalentErrorAttributeNodes(field.Attribute, other, seen) { + return false + } + } + case *Array: + secondType, ok := second.Type.(*Array) + return ok && + firstType.NonNullableElems == secondType.NonNullableElems && + equivalentErrorAttributeNodes(firstType.ElemType, secondType.ElemType, seen) + case *Map: + secondType, ok := second.Type.(*Map) + return ok && + equivalentErrorAttributeNodes(firstType.KeyType, secondType.KeyType, seen) && + equivalentErrorAttributeNodes(firstType.ElemType, secondType.ElemType, seen) + case *Union: + secondType, ok := second.Type.(*Union) + if !ok || + firstType.TypeName != secondType.TypeName || + firstType.GetTypeKey() != secondType.GetTypeKey() || + firstType.GetValueKey() != secondType.GetValueKey() || + len(firstType.Values) != len(secondType.Values) { + return false + } + for index, branch := range firstType.Values { + other := secondType.Values[index] + if branch.Name != other.Name || !equivalentErrorAttributeNodes(branch.Attribute, other.Attribute, seen) { + return false + } + } + default: + return false + } + return true +} + +// equivalentErrorValidation compares validation behavior independently of the +// authored order of required fields and enum values. +func equivalentErrorValidation(first, second *ValidationExpr) bool { + if first == nil { + first = new(ValidationExpr) + } + if second == nil { + second = new(ValidationExpr) + } + firstScalars, secondScalars := *first, *second + firstScalars.Required, secondScalars.Required = nil, nil + firstScalars.Values, secondScalars.Values = nil, nil + return reflect.DeepEqual(firstScalars, secondScalars) && + equivalentStringSet(first.Required, second.Required) && + equivalentValueSet(first.Values, second.Values) +} + +// equivalentStringSet reports whether both slices contain the same distinct +// strings; validation order does not affect runtime behavior. +func equivalentStringSet(first, second []string) bool { + if len(first) != len(second) { + return false + } + for _, value := range first { + if !slices.Contains(second, value) { + return false + } + } + return true +} + +// equivalentValueSet reports whether both enum lists contain the same values +// regardless of declaration order. +func equivalentValueSet(first, second []any) bool { + if len(first) != len(second) { + return false + } + matched := make([]bool, len(second)) + for _, value := range first { + found := false + for index, candidate := range second { + if !matched[index] && reflect.DeepEqual(value, candidate) { + matched[index] = true + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// equivalentErrorMetadata compares metadata keys and ordered values while +// treating nil and empty maps or value slices as the same absent content. +func equivalentErrorMetadata(first, second MetaExpr) bool { + if len(first) != len(second) { + return false + } + for key, values := range first { + other, ok := second[key] + if !ok || !slices.Equal(values, other) { + return false + } + } + return true +} diff --git a/expr/error_contract_test.go b/expr/error_contract_test.go new file mode 100644 index 0000000000..f34a98081b --- /dev/null +++ b/expr/error_contract_test.go @@ -0,0 +1,268 @@ +// This file verifies canonical comparison of reusable transport error value +// contracts independently of authored ordering and explicit default storage. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEquivalentErrorAttributesUseEffectiveUnionKeys(t *testing.T) { + branches := []*NamedAttributeExpr{ + {Name: "text", Attribute: &AttributeExpr{Type: String}}, + } + implicit := &AttributeExpr{Type: &Union{TypeName: "Value", Values: branches}} + explicit := &AttributeExpr{Type: &Union{ + TypeName: "Value", + TypeKey: "type", + ValueKey: "value", + Values: branches, + }} + + require.True(t, equivalentErrorAttributes(implicit, explicit)) +} + +func TestEquivalentErrorAttributesIgnoreRequiredOrder(t *testing.T) { + first := requiredObject("first", "second") + second := requiredObject("second", "first") + + require.True(t, equivalentErrorAttributes(first, second)) +} + +func TestEquivalentErrorAttributesMaterializeBases(t *testing.T) { + base := &UserTypeExpr{ + TypeName: "BaseError", + AttributeExpr: &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + DefaultValue: "invalid", + Meta: MetaExpr{"struct:field:name": {"Message"}}, + }}, + }, + Validation: &ValidationExpr{Required: []string{"message"}}, + }, + } + composed := &AttributeExpr{Type: &Object{}, Bases: []DataType{base}} + explicit := &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + DefaultValue: "invalid", + Meta: MetaExpr{"struct:field:name": {"Message"}}, + }}, + }, + Validation: &ValidationExpr{Required: []string{"message"}}, + } + + require.True(t, equivalentErrorAttributes(composed, explicit)) + require.Len(t, composed.Bases, 1) + require.False(t, composed.finalized) + require.Empty(t, *AsObject(composed.Type)) + require.False(t, base.AttributeExpr.finalized) +} + +func TestEquivalentErrorAttributesRejectDifferentEffectiveBases(t *testing.T) { + stringBase := errorBase("Base", String) + integerBase := errorBase("Base", Int) + first := &AttributeExpr{Type: &Object{}, Bases: []DataType{stringBase}} + second := &AttributeExpr{Type: &Object{}, Bases: []DataType{integerBase}} + + require.False(t, equivalentErrorAttributes(first, second)) +} + +func TestEquivalentErrorAttributesMaterializeReferences(t *testing.T) { + reference := &UserTypeExpr{ + TypeName: "ReferenceError", + AttributeExpr: &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + DefaultValue: "invalid", + }}, + }, + Validation: &ValidationExpr{Required: []string{"message"}}, + }, + } + referenced := &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{Type: String}}, + }, + References: []DataType{reference}, + } + explicit := &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + DefaultValue: "invalid", + }}, + }, + Validation: &ValidationExpr{Required: []string{"message"}}, + } + + require.True(t, equivalentErrorAttributes(referenced, explicit)) + require.Len(t, referenced.References, 1) + require.Nil(t, referenced.Find("message").Validation) + require.Nil(t, referenced.Find("message").DefaultValue) +} + +func TestEquivalentErrorAttributesRejectReferenceContractDrift(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*AttributeExpr) + }{ + {"validation", func(attribute *AttributeExpr) { + minimum := 3 + attribute.Validation.MinLength = &minimum + }}, + {"default", func(attribute *AttributeExpr) { attribute.DefaultValue = "different" }}, + {"metadata", func(attribute *AttributeExpr) { + attribute.Meta["struct:field:name"] = []string{"Different"} + }}, + } { + t.Run(test.name, func(t *testing.T) { + referenced, explicit := referencedErrorContracts() + test.mutate(explicit.Find("message")) + + require.False(t, equivalentErrorAttributes(referenced, explicit)) + }) + } +} + +func TestEquivalentErrorAttributesCompareUnionBranchesPositionally(t *testing.T) { + first := &AttributeExpr{Type: &Union{TypeName: "Value", Values: []*NamedAttributeExpr{ + {Name: "text", Attribute: &AttributeExpr{Type: String}}, + {Name: "count", Attribute: &AttributeExpr{Type: Int}}, + }}} + second := &AttributeExpr{Type: &Union{TypeName: "Value", Values: []*NamedAttributeExpr{ + {Name: "count", Attribute: &AttributeExpr{Type: Int}}, + {Name: "text", Attribute: &AttributeExpr{Type: String}}, + }}} + + require.False(t, equivalentErrorAttributes(first, second)) +} + +func TestEquivalentErrorAttributesCopiesRecursiveDeclarations(t *testing.T) { + first := recursiveErrorType("RecursiveError") + second := recursiveErrorType("RecursiveError") + + require.True(t, equivalentErrorAttributes( + &AttributeExpr{Type: first}, + &AttributeExpr{Type: second}, + )) + require.False(t, first.AttributeExpr.finalized) + require.Same(t, first, first.Find("next").Type) +} + +func TestEffectiveErrorAttributeSharesNoMutableContractValues(t *testing.T) { + minimum := 2 + source := &AttributeExpr{ + Type: String, + Docs: &DocsExpr{Description: "source"}, + Validation: &ValidationExpr{ + MinLength: &minimum, + Values: []any{"first", "second"}, + }, + DefaultValue: []any{map[string]any{"message": "invalid"}}, + UserExamples: []*ExampleExpr{{Value: map[string]any{"message": "invalid"}}}, + } + + effective := effectiveErrorAttribute(source) + *effective.Validation.MinLength = 5 + effective.Validation.Values[0] = "changed" + effective.DefaultValue.([]any)[0].(map[string]any)["message"] = "changed" + effective.Docs.Description = "changed" + effective.UserExamples[0].Value.(map[string]any)["message"] = "changed" + + require.Equal(t, 2, *source.Validation.MinLength) + require.Equal(t, "first", source.Validation.Values[0]) + require.Equal(t, "invalid", source.DefaultValue.([]any)[0].(map[string]any)["message"]) + require.Equal(t, "source", source.Docs.Description) + require.Equal(t, "invalid", source.UserExamples[0].Value.(map[string]any)["message"]) +} + +func TestEffectiveErrorAttributeReconnectsCopiesByOrigin(t *testing.T) { + source := recursiveErrorType("RecursiveError") + first := Dup(source).(UserType) + second := Dup(source).(UserType) + root := &AttributeExpr{Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: first}}, + {Name: "second", Attribute: &AttributeExpr{Type: second}}, + }} + + effective := effectiveErrorAttribute(root) + firstCopy := effective.Find("first").Type.(UserType) + secondCopy := effective.Find("second").Type.(UserType) + + require.Same(t, firstCopy, secondCopy) + require.Same(t, firstCopy, firstCopy.Origin()) + require.NotSame(t, source, firstCopy.Origin()) +} + +// errorBase returns an object declaration whose only field has the given +// primitive type. The shared type name proves comparison uses effective shape. +func errorBase(name string, fieldType DataType) *UserTypeExpr { + return &UserTypeExpr{ + TypeName: name, + AttributeExpr: &AttributeExpr{Type: &Object{ + {Name: "value", Attribute: &AttributeExpr{Type: fieldType}}, + }}, + } +} + +// recursiveErrorType returns an unfinalized self-referential declaration. +func recursiveErrorType(name string) *UserTypeExpr { + result := &UserTypeExpr{TypeName: name} + result.AttributeExpr = &AttributeExpr{Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{Type: String}}, + {Name: "next", Attribute: &AttributeExpr{Type: result}}, + }} + return result +} + +// referencedErrorContracts returns one inherited and one explicit error with +// the same field validation, default, and generated Go name. +func referencedErrorContracts() (*AttributeExpr, *AttributeExpr) { + referenceMinimum := 2 + field := &AttributeExpr{ + Type: String, + Validation: &ValidationExpr{MinLength: &referenceMinimum}, + DefaultValue: "invalid", + Meta: MetaExpr{"struct:field:name": {"Message"}}, + } + reference := &UserTypeExpr{ + TypeName: "ReferenceError", + AttributeExpr: &AttributeExpr{Type: &Object{ + {Name: "message", Attribute: field}, + }}, + } + referenced := &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{Type: String}}, + }, + References: []DataType{reference}, + } + explicitMinimum := 2 + explicit := &AttributeExpr{Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + Validation: &ValidationExpr{MinLength: &explicitMinimum}, + DefaultValue: "invalid", + Meta: MetaExpr{"struct:field:name": {"Message"}}, + }}, + }} + return referenced, explicit +} + +// requiredObject returns the same two-field error object with the requested +// validation order so the test distinguishes authored order from semantics. +func requiredObject(required ...string) *AttributeExpr { + return &AttributeExpr{ + Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: String}}, + {Name: "second", Attribute: &AttributeExpr{Type: String}}, + }, + Validation: &ValidationExpr{Required: required}, + } +} diff --git a/expr/example.go b/expr/example.go index a2f1e32cc9..e10675ba5d 100644 --- a/expr/example.go +++ b/expr/example.go @@ -1,3 +1,6 @@ +// This file generates JSON-compatible examples from evaluated attributes. Each +// service, method, type, and field uses its own repeatable sequence so changes +// elsewhere do not change its example values. package expr import ( @@ -18,6 +21,13 @@ const ( // isn't such a value then Example computes a random value for the attribute // using the given random value producer. func (a *AttributeExpr) Example(r *ExampleGenerator) any { + if r.factory == nil { + return nil + } + if r.exampleRandomizer == nil { + panic("example generator must be anchored before drawing a value") + } + if ex := a.ExtractUserExamples(); len(ex) > 0 { // Return the last item in the slice so that examples can be overridden // in the DSL. Overridden examples are always appended to the UserExamples @@ -25,10 +35,6 @@ func (a *AttributeExpr) Example(r *ExampleGenerator) any { return ex[len(ex)-1].Value } - if r.Randomizer == nil { - return nil - } - value, ok := a.Meta.Last("openapi:example") if !ok { value, ok = a.Meta.Last("swagger:example") @@ -157,15 +163,15 @@ func byLength(a *AttributeExpr, r *ExampleGenerator) any { case MapKind: raw := make(map[any]any) m := dt.(*Map) - for range count { - raw[m.KeyType.Example(r)] = m.ElemType.Example(r) + for i := range count { + raw[m.KeyType.Example(r.MapKey(i))] = m.ElemType.Example(r.MapValue(i)) } return m.MakeMap(raw) case ArrayKind: raw := make([]any, count) ar := dt.(*Array) for i := range count { - raw[i] = ar.ElemType.Example(r) + raw[i] = ar.ElemType.Example(r.ArrayElement(i)) } return ar.MakeSlice(raw) default: diff --git a/expr/example_identity.go b/expr/example_identity.go new file mode 100644 index 0000000000..82ca6eb27d --- /dev/null +++ b/expr/example_identity.go @@ -0,0 +1,268 @@ +// This file builds repeatable keys for example values from evaluated service, +// method, type, field, and transport names. +package expr + +import ( + "encoding/base64" + "encoding/binary" +) + +type ( + // ExampleIdentity selects a repeatable sequence of generated example values. + // Equal values select the same sequence. Its fields are private so callers + // must use the constructors below. + ExampleIdentity struct { + seed string + } + + exampleIdentityKind byte +) + +const ( + userTypeExampleKind exampleIdentityKind = iota + 1 + methodPayloadExampleKind + methodResultExampleKind + methodStreamingPayloadExampleKind + methodStreamingResultExampleKind + methodErrorExampleKind + httpRequestBodyExampleKind + httpResponseBodyExampleKind + httpErrorResponseBodyExampleKind + jsonRPCRequestBodyExampleKind + jsonRPCResponseBodyExampleKind + jsonRPCErrorResponseBodyExampleKind + grpcRequestMessageExampleKind + grpcResponseMessageExampleKind + grpcStreamingRequestMessageExampleKind + grpcStreamingResponseMessageExampleKind + grpcErrorMessageExampleKind + grpcArrayWrapperExampleKind + grpcMapWrapperExampleKind + memberExampleKind + arrayElementExampleKind + mapKeyExampleKind + mapValueExampleKind + unionMemberExampleKind +) + +// UserTypeExampleIdentity returns the example key for typ. +func UserTypeExampleIdentity(typ UserType) ExampleIdentity { + if identity, ok := GeneratedUserTypeExampleIdentity(typ); ok { + return identity + } + return newExampleIdentity(userTypeExampleKind, []byte(typ.ID())) +} + +// GeneratedUserTypeExampleIdentity returns the example key stored on a user +// type created by Goa. The second result is false for types written in the +// design. +func GeneratedUserTypeExampleIdentity(typ UserType) (ExampleIdentity, bool) { + var identity ExampleIdentity + switch generated := typ.(type) { + case *UserTypeExpr: + identity = generated.exampleIdentity + case *ResultTypeExpr: + identity = generated.UserTypeExpr.exampleIdentity + } + return identity, identity.seed != "" +} + +// MethodPayloadExampleIdentity returns the example key for method's payload. +func MethodPayloadExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(methodPayloadExampleKind, method) +} + +// MethodResultExampleIdentity returns the example key for method's result. +func MethodResultExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(methodResultExampleKind, method) +} + +// MethodStreamingPayloadExampleIdentity returns the example key for method's +// streaming payload. +func MethodStreamingPayloadExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(methodStreamingPayloadExampleKind, method) +} + +// MethodStreamingResultExampleIdentity returns the example key for method's +// streaming result. +func MethodStreamingResultExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(methodStreamingResultExampleKind, method) +} + +// MethodErrorExampleIdentity returns the example key for err in method. +func MethodErrorExampleIdentity(method *MethodExpr, err *ErrorExpr) ExampleIdentity { + return newExampleIdentity( + methodErrorExampleKind, + []byte(method.Service.Name), + []byte(method.Name), + []byte(err.Name), + ) +} + +// RequestBodyExampleIdentity returns the example key for endpoint's request +// body. HTTP and JSON-RPC endpoints receive different keys. +func RequestBodyExampleIdentity(endpoint *HTTPEndpointExpr) ExampleIdentity { + kind := httpRequestBodyExampleKind + if endpoint.IsJSONRPC() { + kind = jsonRPCRequestBodyExampleKind + } + return newExampleIdentity( + kind, + []byte(endpoint.MethodExpr.Service.Name), + []byte(endpoint.MethodExpr.Name), + ) +} + +// ResponseBodyExampleIdentity returns the example key for a successful response +// body. HTTP and JSON-RPC endpoints receive different keys, and each successful +// status code receives its own key. +func ResponseBodyExampleIdentity(endpoint *HTTPEndpointExpr, response *HTTPResponseExpr) ExampleIdentity { + kind := httpResponseBodyExampleKind + if endpoint.IsJSONRPC() { + kind = jsonRPCResponseBodyExampleKind + } + return newExampleIdentity( + kind, + []byte(endpoint.MethodExpr.Service.Name), + []byte(endpoint.MethodExpr.Name), + exampleIdentityInt(response.StatusCode), + ) +} + +// ErrorResponseBodyExampleIdentity returns the example key for an error response +// body. HTTP and JSON-RPC endpoints receive different keys. The error name keeps +// two errors with the same HTTP status separate. +func ErrorResponseBodyExampleIdentity(endpoint *HTTPEndpointExpr, response *HTTPErrorExpr) ExampleIdentity { + kind := httpErrorResponseBodyExampleKind + if endpoint.IsJSONRPC() { + kind = jsonRPCErrorResponseBodyExampleKind + } + return newExampleIdentity( + kind, + []byte(endpoint.MethodExpr.Service.Name), + []byte(endpoint.MethodExpr.Name), + []byte(response.Name), + exampleIdentityInt(response.Response.StatusCode), + ) +} + +// GRPCRequestMessageExampleIdentity returns the example key for method's gRPC +// request message. +func GRPCRequestMessageExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(grpcRequestMessageExampleKind, method) +} + +// GRPCResponseMessageExampleIdentity returns the example key for method's gRPC +// response message. +func GRPCResponseMessageExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(grpcResponseMessageExampleKind, method) +} + +// GRPCStreamingRequestMessageExampleIdentity returns the example key for +// method's streaming gRPC request message. +func GRPCStreamingRequestMessageExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(grpcStreamingRequestMessageExampleKind, method) +} + +// GRPCStreamingResponseMessageExampleIdentity returns the example key for +// method's streaming gRPC response message. +func GRPCStreamingResponseMessageExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(grpcStreamingResponseMessageExampleKind, method) +} + +// GRPCErrorMessageExampleIdentity returns the example key for err's gRPC +// message in method. +func GRPCErrorMessageExampleIdentity(method *MethodExpr, err *ErrorExpr) ExampleIdentity { + return newExampleIdentity( + grpcErrorMessageExampleKind, + []byte(method.Service.Name), + []byte(method.Name), + []byte(err.Name), + ) +} + +// GRPCArrayWrapperExampleIdentity returns the example key for the gRPC message +// that wraps an array type written in the design. +func GRPCArrayWrapperExampleIdentity(typ UserType) ExampleIdentity { + if !IsArray(typ) { + panic("gRPC array wrapper identity requires an array user type") + } + return newExampleIdentity(grpcArrayWrapperExampleKind, []byte(typ.Origin().ID())) +} + +// GRPCMapWrapperExampleIdentity returns the example key for the gRPC message +// that wraps a map type written in the design. +func GRPCMapWrapperExampleIdentity(typ UserType) ExampleIdentity { + if !IsMap(typ) { + panic("gRPC map wrapper identity requires a map user type") + } + return newExampleIdentity(grpcMapWrapperExampleKind, []byte(typ.Origin().ID())) +} + +// Seed returns the complete encoded key passed to custom randomizer factories. +func (i ExampleIdentity) Seed() string { + return base64.RawURLEncoding.EncodeToString([]byte(i.seed)) +} + +// Member returns the example key for name within i. +func (i ExampleIdentity) Member(name string) ExampleIdentity { + return i.append(memberExampleKind, []byte(name)) +} + +// ArrayElement returns the example key for index within the array at i. +func (i ExampleIdentity) ArrayElement(index int) ExampleIdentity { + return i.append(arrayElementExampleKind, exampleIdentityInt(index)) +} + +// MapKey returns the example key for key index within the map at i. +func (i ExampleIdentity) MapKey(index int) ExampleIdentity { + return i.append(mapKeyExampleKind, exampleIdentityInt(index)) +} + +// MapValue returns the example key for value index within the map at i. +func (i ExampleIdentity) MapValue(index int) ExampleIdentity { + return i.append(mapValueExampleKind, exampleIdentityInt(index)) +} + +// UnionMember returns the example key for name within the union at i. +func (i ExampleIdentity) UnionMember(name string) ExampleIdentity { + return i.append(unionMemberExampleKind, []byte(name)) +} + +// A new example key encodes a value kind and each component's byte length so +// different component lists cannot produce the same key. +func newExampleIdentity(kind exampleIdentityKind, components ...[]byte) ExampleIdentity { + return ExampleIdentity{seed: string(appendExampleIdentitySegment(nil, kind, components...))} +} + +// Method example keys are built from the evaluated service and method names. +func methodExampleIdentity(kind exampleIdentityKind, method *MethodExpr) ExampleIdentity { + return newExampleIdentity(kind, []byte(method.Service.Name), []byte(method.Name)) +} + +// Integers in example keys are written as eight bytes in big-endian order. +func exampleIdentityInt(value int) []byte { + return binary.BigEndian.AppendUint64(nil, uint64(value)) +} + +// Each added part writes its kind, number of components, and every component's +// byte length before the component bytes. +func appendExampleIdentitySegment(seed []byte, kind exampleIdentityKind, components ...[]byte) []byte { + seed = append(seed, byte(kind)) + seed = binary.BigEndian.AppendUint64(seed, uint64(len(components))) + for _, component := range components { + seed = binary.BigEndian.AppendUint64(seed, uint64(len(component))) + seed = append(seed, component...) + } + return seed +} + +// append adds one member, array, map, or union part to the current key. +func (i ExampleIdentity) append(kind exampleIdentityKind, components ...[]byte) ExampleIdentity { + if i.seed == "" { + panic("example identity must have a semantic owner before structural descent") + } + seed := append([]byte(nil), i.seed...) + seed = appendExampleIdentitySegment(seed, kind, components...) + return ExampleIdentity{seed: string(seed)} +} diff --git a/expr/example_stability_test.go b/expr/example_stability_test.go index 95e4d17016..0391e5f98e 100644 --- a/expr/example_stability_test.go +++ b/expr/example_stability_test.go @@ -1,3 +1,5 @@ +// This file verifies that typed example owners make values independent of +// unrelated draws while preserving member-local values in composite examples. package expr_test import ( @@ -27,11 +29,16 @@ func TestExampleOrderIndependence(t *testing.T) { } // Reference value computed on a fresh generator. - ref := newUT("Stable").Example(expr.NewRandom("test")) + stable := newUT("Stable") + ref := stable.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.UserTypeExampleIdentity(stable), + )) require.NotNil(t, ref) // Same value after unrelated draws were consumed from the generator. - r := expr.NewRandom("test") + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(exampleMethod("noise", "draw")), + ) noise := make([]any, 0, 14) for range 7 { noise = append(noise, r.Int(), r.String()) @@ -40,8 +47,11 @@ func TestExampleOrderIndependence(t *testing.T) { require.Equal(t, ref, newUT("Stable").Example(r)) // Same value after another type's example was computed first. - r = expr.NewRandom("test") - newUT("Other").Example(r) + r = expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(exampleMethod("noise", "other")), + ) + other := newUT("Other") + other.Example(r.At(expr.UserTypeExampleIdentity(other))) require.Equal(t, ref, newUT("Stable").Example(r)) } @@ -57,8 +67,9 @@ func TestExampleFieldLocality(t *testing.T) { {Name: "b", Attribute: &expr.AttributeExpr{Type: expr.Int}}, {Name: "c", Attribute: &expr.AttributeExpr{Type: expr.Boolean}}, } - exSmall := small.Example(expr.NewRandom("test")).(map[string]any) - exLarge := large.Example(expr.NewRandom("test")).(map[string]any) + owner := expr.MethodPayloadExampleIdentity(exampleMethod("locality", "object")) + exSmall := small.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At(owner)).(map[string]any) + exLarge := large.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At(owner)).(map[string]any) require.Equal(t, exSmall["a"], exLarge["a"]) require.Equal(t, exSmall["b"], exLarge["b"]) } @@ -77,9 +88,8 @@ func TestExampleFieldAnchor(t *testing.T) { }, }, } - parent := &expr.AttributeExpr{Type: ut} - - composite := ut.Example(expr.NewRandom("test")).(map[string]any) - standalone := field.Example(expr.NewRandom("test").Field(parent, "id")) + identity := expr.UserTypeExampleIdentity(ut) + composite := ut.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At(identity)).(map[string]any) + standalone := field.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At(identity).Member("id")) require.Equal(t, composite["id"], standalone) } diff --git a/expr/example_test.go b/expr/example_test.go index 9efbeb9e06..57541d486b 100644 --- a/expr/example_test.go +++ b/expr/example_test.go @@ -1,3 +1,5 @@ +// This file exercises attribute example generation across validation rules and +// confirms every configured generator is anchored to its owning expression. package expr_test import ( @@ -21,11 +23,13 @@ func TestByPattern(t *testing.T) { {"max-len", "foo[a-z]+", 9}, {"max-len-2", "^/api/example/[0-9]+$", 19}, } - r := expr.NewRandom("test") for _, k := range cases { t.Run(k.Name, func(t *testing.T) { val := &expr.ValidationExpr{Pattern: k.Pattern} att := expr.AttributeExpr{Validation: val} + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(exampleMethod("pattern", k.Name)), + ) example := att.Example(r).(string) @@ -42,7 +46,9 @@ func TestByPattern(t *testing.T) { func TestByFormatUUID(t *testing.T) { val := &expr.ValidationExpr{Format: expr.FormatUUID} att := expr.AttributeExpr{Validation: val} - r := expr.NewRandom("test") + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(exampleMethod("format", "uuid")), + ) example := att.Example(r).(string) if !regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`).MatchString(example) { t.Errorf("got %s, expected a match with `[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}`", example) @@ -68,12 +74,15 @@ func TestExample(t *testing.T) { {"openapi-generate-false-array-example", testdata.OpenAPIGenerateFalseArrayExampleDSL, map[string]any{"items": []map[string]any{{"name": "example"}}}, ""}, {"overriding-hidden-examples", testdata.OverridingHiddenExamplesDSL, "example", ""}, } - r := expr.NewRandom("test") for _, k := range cases { t.Run(k.Name, func(t *testing.T) { if k.Error == "" { expr.RunDSL(t, k.DSL) - example := expr.Root.Services[0].Methods[0].Payload.Example(r) + method := expr.Root.Services[0].Methods[0] + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(method), + ) + example := method.Payload.Example(r) if !reflect.DeepEqual(example, k.Expected) { t.Errorf("invalid example: got %v, expected %v", example, k.Expected) } @@ -92,14 +101,15 @@ func TestExample(t *testing.T) { // can generate examples correctly. Previously, this would panic because the // code checked a.Type.Kind() instead of the underlying type's kind. func TestByLengthWithAliasType(t *testing.T) { - r := expr.NewRandom("test") - // Create an alias type based on String with length validation // We need to use the DSL package properly root := expr.RunDSL(t, testdata.AliasLengthValidationDSL) aliasType := root.UserType("ValidatedString") att := aliasType.Attribute() + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.UserTypeExampleIdentity(aliasType), + ) // This should not panic and should generate a string example // The key test is that byLength handles alias types correctly by unaliasing @@ -119,12 +129,13 @@ func TestByLengthWithAliasType(t *testing.T) { // TestByLengthWithAliasArray tests that alias array types with length // validations generate examples correctly. func TestByLengthWithAliasArray(t *testing.T) { - r := expr.NewRandom("test") - root := expr.RunDSL(t, testdata.AliasArrayLengthValidationDSL) aliasType := root.UserType("StringArray") att := aliasType.Attribute() + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.UserTypeExampleIdentity(aliasType), + ) // This should not panic and should generate an array example example := att.Example(r) diff --git a/expr/grpc_endpoint.go b/expr/grpc_endpoint.go index 11a0f76d45..2e621361b4 100644 --- a/expr/grpc_endpoint.go +++ b/expr/grpc_endpoint.go @@ -1,3 +1,5 @@ +// This file prepares, validates, and finalizes the gRPC transport contract for +// one service method. package expr import ( @@ -118,7 +120,7 @@ func (e *GRPCEndpointExpr) Prepare() { continue } // Lookup undefined GRPC errors in API. - for _, v := range Root.API.GRPC.Errors { + for _, v := range e.MethodExpr.Service.design.API.GRPC.Errors { if me.Name == v.Name { e.GRPCErrors = append(e.GRPCErrors, v.Dup()) } @@ -138,7 +140,7 @@ func (e *GRPCEndpointExpr) Prepare() { } } if !found { - for _, ae := range Root.API.GRPC.Errors { + for _, ae := range e.MethodExpr.Service.design.API.GRPC.Errors { if se.Name == ae.Name { e.GRPCErrors = append(e.GRPCErrors, ae.Dup()) break @@ -171,6 +173,9 @@ func (e *GRPCEndpointExpr) Validate() error { if e.Name() == "" { verr.Add(e, "Endpoint name cannot be empty") } + if e.MethodExpr.HasMixedResults() { + verr.Add(e, "gRPC method %q cannot define both Result and StreamingResult because one gRPC call cannot return a separate result after its response stream", e.MethodExpr.Name) + } verr.Merge(e.validateStreamCompat()) seenUnions := make(map[*Union]struct{}) @@ -247,10 +252,36 @@ func (e *GRPCEndpointExpr) Validate() error { // their fields must define field numbers, mirroring the payload and // result checks above. Default ErrorResult errors travel in the gRPC // status and need no tags. - if ee := e.MethodExpr.Error(er.Name); ee != nil && ee.Type != ErrorResult && IsObject(ee.Type) { + if ee := e.MethodExpr.Error(er.Name); ee != nil && !IsErrorResult(ee.Type) && IsObject(ee.Type) { verr.Merge(validateRPCTags(AsObject(ee.Type), e)) } } + verr.Merge(e.validateErrorMappings()) + return verr +} + +// validateErrorMappings ensures inherited gRPC response policy describes the +// same concrete error value returned by the endpoint method. +func (e *GRPCEndpointExpr) validateErrorMappings() *eval.ValidationErrors { + verr := new(eval.ValidationErrors) + for _, mapping := range e.GRPCErrors { + mapped, owner := mapping.mappedError(e.MethodExpr.Service.design) + method := e.MethodExpr.Error(mapping.Name) + if mapped == nil || method == nil || equivalentErrorAttributes(mapped.AttributeExpr, method.AttributeExpr) { + continue + } + verr.Add( + mapping.Response, + `gRPC error mapping %q inherited from the %s uses error type %q, but method %q of service %q uses %q; both definitions must define the same error attribute; %s`, + mapping.Name, + owner, + mapped.Type.Name(), + e.MethodExpr.Name, + e.MethodExpr.Service.Name, + method.Type.Name(), + errorAttributeDifference(mapped.AttributeExpr, method.AttributeExpr), + ) + } return verr } @@ -545,6 +576,8 @@ func validateMetadata(metAtt *MappedAttributeExpr, serviceAtt *AttributeExpr, e for _, nat := range *AsObject(metAtt.Type) { if a := serviceAtt.Find(nat.Name); a == nil { verr.Add(e, "%s metadata attribute %q is not found in %s", metKind, nat.Name, serviceKind) + } else if !isMetadataEncodable(a.Type) { + verr.Add(e, "%s metadata attribute %q must be a primitive or an array of primitives, got %s", metKind, nat.Name, a.Type.Name()) } } } else { @@ -602,7 +635,7 @@ func (e *GRPCEndpointExpr) streamCompatValue() (string, bool) { if v, ok := e.Service.ServiceExpr.Meta.Last(streamCompatMetaKey); ok { return v, true } - if v, ok := Root.API.Meta.Last(streamCompatMetaKey); ok { + if v, ok := e.MethodExpr.Service.design.API.Meta.Last(streamCompatMetaKey); ok { return v, true } return "", false diff --git a/expr/grpc_endpoint_test.go b/expr/grpc_endpoint_test.go index 953e1430d0..cee9bfd6d3 100644 --- a/expr/grpc_endpoint_test.go +++ b/expr/grpc_endpoint_test.go @@ -1,3 +1,5 @@ +// This file verifies gRPC endpoint preparation and validation, including the +// native primitive contract required by request and response metadata. package expr_test import ( @@ -20,6 +22,22 @@ func TestGRPCEndpointValidation(t *testing.T) { DSL: testdata.GRPCEndpointWithAnyType, Errors: []string{}, // Any type is now supported in gRPC }, + "endpoint-with-mixed-results": { + DSL: testdata.GRPCEndpointWithMixedResults, + Errors: []string{ + `service "Service" gRPC endpoint "Method": gRPC method "Method" cannot define both Result and StreamingResult because one gRPC call cannot return a separate result after its response stream`, + }, + }, + "endpoint-with-matching-mixed-results": { + DSL: testdata.GRPCEndpointWithMatchingMixedResults, + Errors: []string{ + `service "Service" gRPC endpoint "Method": gRPC method "Method" cannot define both Result and StreamingResult because one gRPC call cannot return a separate result after its response stream`, + }, + }, + "endpoint-with-streaming-result": { + DSL: testdata.GRPCEndpointWithStreamingResult, + Errors: []string{}, + }, "endpoint-with-untagged-fields": { DSL: testdata.GRPCEndpointWithUntaggedFields, Errors: []string{`service "Service" gRPC endpoint "Method": attribute "req_not_field" does not have "rpc:tag" defined in the meta, use "Field" to define the attribute of a type used in a gRPC method @@ -40,6 +58,15 @@ service "Service" gRPC endpoint "Method": field number 2 in attribute "key_dup_i DSL: testdata.GRPCEndpointWithExtendedTypes, Errors: []string{}, }, + "endpoint-with-composite-metadata": { + DSL: testdata.GRPCEndpointWithCompositeMetadata, + Errors: []string{`service "Service" gRPC endpoint "Method": Request metadata attribute "object" must be a primitive or an array of primitives, got MetadataObject +service "Service" gRPC endpoint "Method": Request metadata attribute "mapping" must be a primitive or an array of primitives, got map +service "Service" gRPC endpoint "Method": Request metadata attribute "choice" must be a primitive or an array of primitives, got choice +service "Service" gRPC endpoint "Method": Response metadata attribute "object" must be a primitive or an array of primitives, got MetadataObject +service "Service" gRPC endpoint "Method": Response metadata attribute "mapping" must be a primitive or an array of primitives, got map +service "Service" gRPC endpoint "Method": Response metadata attribute "choice" must be a primitive or an array of primitives, got choice`}, + }, "endpoint-with-inherit-error": { DSL: testdata.GRPCEndpointWithInheritErrorDSL, Errors: []string{}, diff --git a/expr/grpc_error.go b/expr/grpc_error.go index b387254e91..becda5e3d0 100644 --- a/expr/grpc_error.go +++ b/expr/grpc_error.go @@ -1,3 +1,5 @@ +// This file binds reusable gRPC error-response policy to the concrete error +// returned by each endpoint method. package expr import ( @@ -36,7 +38,7 @@ func (e *GRPCErrorExpr) Validate() *eval.ValidationErrors { verr.Add(e, "Error %#v does not match an error defined in the service", e.Name) } case *RootExpr: - if Root.Error(e.Name) == nil { + if p.Error(e.Name) == nil { verr.Add(e, "Error %#v does not match an error defined in the API", e.Name) } } @@ -45,17 +47,24 @@ func (e *GRPCErrorExpr) Validate() *eval.ValidationErrors { // Finalize looks up the corresponding method error expression. func (e *GRPCErrorExpr) Finalize(a *GRPCEndpointExpr) { - var ee *ErrorExpr - switch p := e.Response.Parent.(type) { + e.ErrorExpr = a.MethodExpr.Error(e.Name) + e.Response.Finalize(a, e.AttributeExpr) +} + +// mappedError returns the error declaration described by this reusable gRPC +// response before it is copied to an endpoint. +func (e *GRPCErrorExpr) mappedError(root *RootExpr) (*ErrorExpr, string) { + switch parent := e.Response.Parent.(type) { case *GRPCEndpointExpr: - ee = p.MethodExpr.Error(e.Name) + return parent.MethodExpr.Error(e.Name), "method" case *GRPCServiceExpr: - ee = p.Error(e.Name) + return parent.Error(e.Name), "service" case *GRPCExpr: - ee = Root.Error(e.Name) + return root.Error(e.Name), "API" + case *RootExpr: + return parent.Error(e.Name), "API" } - e.ErrorExpr = ee - e.Response.Finalize(a, e.AttributeExpr) + return nil, "" } // Dup creates a copy of the error expression. diff --git a/expr/grpc_service.go b/expr/grpc_service.go index 2fffe92eba..23d01f91e6 100644 --- a/expr/grpc_service.go +++ b/expr/grpc_service.go @@ -62,12 +62,7 @@ func (svc *GRPCServiceExpr) EndpointFor(name string, m *MethodExpr) *GRPCEndpoin // Error returns the error with the given name. func (svc *GRPCServiceExpr) Error(name string) *ErrorExpr { - for _, erro := range svc.ServiceExpr.Errors { - if erro.Name == name { - return erro - } - } - return Root.Error(name) + return svc.ServiceExpr.Error(name) } // GRPCError returns the service gRPC error with given name if any. @@ -102,7 +97,7 @@ func (svc *GRPCServiceExpr) Validate() error { for _, er := range svc.GRPCErrors { verr.Merge(er.Validate()) } - for _, er := range Root.API.GRPC.Errors { + for _, er := range svc.ServiceExpr.design.API.GRPC.Errors { // This may result in the same error being validated multiple // times however service is the top level expression being // walked and errors cannot be walked until all expressions have diff --git a/expr/http_authored_attribute_test.go b/expr/http_authored_attribute_test.go new file mode 100644 index 0000000000..7782b4a5f4 --- /dev/null +++ b/expr/http_authored_attribute_test.go @@ -0,0 +1,71 @@ +// This file verifies that copied HTTP request and response fields still point +// to the service fields that supplied their descriptions and examples. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHTTPBodyAttributesKeepAuthoredAttribute(t *testing.T) { + payloadChild := &AttributeExpr{Type: String} + payload := &AttributeExpr{Type: &Object{{Name: "message", Attribute: payloadChild}}} + resultChild := &AttributeExpr{Type: String} + result := &AttributeExpr{Type: &Object{{Name: "message", Attribute: resultChild}}} + serviceMethod := &ServiceExpr{Name: "messages"} + serviceMethod.design = &RootExpr{API: NewAPIExpr("messages", nil)} + method := &MethodExpr{ + Name: "show", + Service: serviceMethod, + Payload: payload, + Result: result, + } + service := &HTTPServiceExpr{ServiceExpr: serviceMethod} + endpoint := &HTTPEndpointExpr{ + MethodExpr: method, + Service: service, + Params: NewEmptyMappedAttributeExpr(), + Headers: NewEmptyMappedAttributeExpr(), + Cookies: NewEmptyMappedAttributeExpr(), + } + response := &HTTPResponseExpr{ + Headers: NewEmptyMappedAttributeExpr(), + Cookies: NewEmptyMappedAttributeExpr(), + } + + requestBody := httpRequestBody(endpoint) + responseBody := buildHTTPResponseBody("show", result, response, MethodResultExampleIdentity(method)) + + require.Same(t, payload, requestBody.AuthoredAttribute()) + require.Same(t, result, responseBody.AuthoredAttribute()) + require.Same(t, payloadChild, AsObject(requestBody.Type).Attribute("message").AuthoredAttribute()) + require.Same(t, resultChild, AsObject(responseBody.Type).Attribute("message").AuthoredAttribute()) +} + +func TestHTTPStreamingBodyKeepsAuthoredAttribute(t *testing.T) { + streaming := &AttributeExpr{Type: &Object{{Name: "message", Attribute: &AttributeExpr{Type: String}}}} + method := &MethodExpr{ + Name: "watch", + Service: &ServiceExpr{Name: "events"}, + Payload: &AttributeExpr{Type: Empty}, + Result: &AttributeExpr{Type: Empty}, + StreamingPayload: streaming, + Stream: ClientStreamKind, + } + endpoint := &HTTPEndpointExpr{MethodExpr: method} + + body := httpStreamingBody(endpoint) + + require.Same(t, streaming, body.AuthoredAttribute()) +} + +func TestHTTPPlaceholderKeepsAuthoredAttribute(t *testing.T) { + authored := &AttributeExpr{Type: String, Description: "description"} + placeholder := &AttributeExpr{Type: String} + + initAttrFromDesign(placeholder, authored) + + require.Same(t, authored, placeholder.AuthoredAttribute()) + require.Equal(t, "description", placeholder.Description) +} diff --git a/expr/http_body_types.go b/expr/http_body_types.go index d681e90c0c..7cf50898e3 100644 --- a/expr/http_body_types.go +++ b/expr/http_body_types.go @@ -1,67 +1,13 @@ +// HTTP body type helpers derive request and response shapes from service types +// without changing the original design declarations. package expr import ( - "encoding/json" - "fmt" "net/http" "strings" "unicode" ) -// UnionToObject returns an object adequate to serialize the given union type in -// HTTP requests and responses. The object has two fields for the discriminator -// and value, with names determined by the union's Meta tags (defaulting to -// "Type" and "Value"). The discriminator field indicates the name of the union -// type, and the value field contains the JSON encoded union value. -func UnionToObject(att *AttributeExpr) *AttributeExpr { - example := att.Example(Root.API.ExampleGenerator) - js, err := json.Marshal(example) - if err != nil { - js = []byte("null") - } - union := AsUnion(att.Type) - values := union.Values - typeKey := union.GetTypeKey() - valueKey := union.GetValueKey() - - names := make([]any, len(values)) - vals := make([]string, len(values)) - bases := make([]DataType, len(values)) - for i, nat := range values { - names[i] = nat.Name - vals[i] = fmt.Sprintf("- %q", nat.Name) - bases[i] = nat.Attribute.Type - } - obj := Object([]*NamedAttributeExpr{ - {Name: typeKey, Attribute: &AttributeExpr{ - Type: String, - Description: "Union type name, one of:\n" + strings.Join(vals, "\n"), - Validation: &ValidationExpr{Values: names}, - Meta: MetaExpr{ - "struct:tag:form": {typeKey}, - "struct:tag:json": {typeKey}, - "struct:tag:xml": {typeKey}, - }, - }}, - {Name: valueKey, Attribute: &AttributeExpr{ - Type: String, - Description: "JSON encoded union value", - UserExamples: []*ExampleExpr{{Value: string(js)}}, - Bases: bases, // For OpenAPI generation - Meta: MetaExpr{ - "struct:tag:form": {valueKey}, - "struct:tag:json": {valueKey}, - "struct:tag:xml": {valueKey}, - }, - }}, - }) - return &AttributeExpr{ - Type: &obj, - Description: att.Description, - Validation: &ValidationExpr{Required: []string{typeKey, valueKey}}, - } -} - // defaultRequestHeaderAttributes returns a map keyed by the names of the // payload attributes that should come from the request HTTP headers by default. // This includes mapping done for certain authorization schemes (basic auth, @@ -80,8 +26,8 @@ func defaultRequestHeaderAttributes(e *HTTPEndpointExpr) map[string]bool { requirements = e.MethodExpr.Requirements case len(e.Service.ServiceExpr.Requirements) > 0: requirements = e.Service.ServiceExpr.Requirements - case len(Root.API.Requirements) > 0: - requirements = Root.API.Requirements + case len(e.MethodExpr.Service.design.API.Requirements) > 0: + requirements = e.MethodExpr.Service.design.API.Requirements } if len(requirements) == 0 { return nil @@ -129,15 +75,17 @@ func defaultRequestHeaderAttributes(e *HTTPEndpointExpr) map[string]bool { // by removing the attributes of the method payload used to define headers and // parameters. func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { - const suffix = "RequestBody" var ( name = concat(a.Name(), "Request", "Body") ) if a.Body != nil { + if a.Body.Type == Empty { + return a.Body + } a.Body = DupAtt(a.Body) - renameType(a.Body, name, suffix) - if ut, ok := a.Body.Type.(*UserTypeExpr); ok { - ut.UID = a.Service.Name() + "#" + name + renameType(a.Body, name) + if ut, ok := a.Body.Type.(UserType); ok { + a.Body.Type = generatedUserType(ut, RequestBodyExampleIdentity(a)) } return a.Body } @@ -150,7 +98,6 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { bodyOnly = headers.IsEmpty() && params.IsEmpty() && cookies.IsEmpty() && a.MapQueryParams == nil ) - // 1. If Payload is not an object then check whether there are // 2. If Payload is not an object then check whether there are // params, cookies or headers defined and if so return empty type // (payload encoded in request params or headers) otherwise return @@ -159,7 +106,7 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { if bodyOnly { payload = DupAtt(payload) RemovePkgPath(payload) - renameType(payload, name, suffix) + renameType(payload, name) return payload } return &AttributeExpr{Type: Empty} @@ -186,13 +133,7 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { // 5. Build computed user type att := body.Attribute() - ut := &UserTypeExpr{ - AttributeExpr: att, - TypeName: name, - UID: a.Service.Name() + "#" + a.Name(), - } - appendSuffix(ut.Attribute().Type, suffix) - + ut := NewGeneratedUserType(name, att, RequestBodyExampleIdentity(a)) if t, ok := payload.Type.(UserType); ok { copyOpenAPITypeMeta(t, ut) } @@ -201,6 +142,7 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { Type: ut, Validation: att.Validation, UserExamples: att.UserExamples, + authored: payload.AuthoredAttribute(), } } @@ -214,7 +156,6 @@ func httpStreamingBody(e *HTTPEndpointExpr) *AttributeExpr { if !IsObject(att.Type) { return DupAtt(att) } - const suffix = "StreamingBody" dupped := DupAtt(att) // Method attributes that reference user types keep validation on the // referenced type. Promote it to the computed body so HTTP type generation @@ -225,17 +166,17 @@ func httpStreamingBody(e *HTTPEndpointExpr) *AttributeExpr { } } RemovePkgPath(dupped) - appendSuffix(dupped.Type, suffix) - ut := &UserTypeExpr{ - AttributeExpr: dupped, - TypeName: concat(e.Name(), "Streaming", "Body"), - UID: e.Service.Name() + "#" + e.Name() + "StreamingBody", - } + ut := NewGeneratedUserType( + concat(e.Name(), "Streaming", "Body"), + dupped, + MethodStreamingPayloadExampleIdentity(e.MethodExpr), + ) return &AttributeExpr{ Type: ut, Validation: dupped.Validation, UserExamples: att.UserExamples, + authored: att.AuthoredAttribute(), } } @@ -250,7 +191,8 @@ func httpResponseBody(a *HTTPEndpointExpr, resp *HTTPResponseExpr) *AttributeExp suffix = http.StatusText(resp.StatusCode) } name = a.Name() + suffix - return buildHTTPResponseBody(name, a.MethodExpr.Result, resp, a.Service) + identity := ResponseBodyExampleIdentity(a, resp) + return buildHTTPResponseBody(name, a.MethodExpr.Result, resp, identity) } // httpErrorResponseBody returns an attribute describing the response body of a @@ -260,11 +202,11 @@ func httpResponseBody(a *HTTPEndpointExpr, resp *HTTPResponseExpr) *AttributeExp // parameters. func httpErrorResponseBody(e *HTTPEndpointExpr, v *HTTPErrorExpr) *AttributeExpr { name := e.Name() + "_" + v.ErrorExpr.Name - return buildHTTPResponseBody(name, v.AttributeExpr, v.Response, e.Service) + identity := ErrorResponseBodyExampleIdentity(e, v) + return buildHTTPResponseBody(name, v.AttributeExpr, v.Response, identity) } -func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseExpr, svc *HTTPServiceExpr) *AttributeExpr { - const suffix = "ResponseBody" +func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseExpr, identity ExampleIdentity) *AttributeExpr { name = concat(name, "Response", "Body") if attr == nil || attr.Type == Empty { return &AttributeExpr{Type: Empty} @@ -282,12 +224,9 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE return &AttributeExpr{Type: Empty} } att := DupAtt(resp.Body) - renameType(att, name, suffix) - if ut, ok := att.Type.(*UserTypeExpr); ok { - ut.UID = svc.Name() + "#" + name - } - if rt, ok := att.Type.(*ResultTypeExpr); ok { - rt.UID = svc.Name() + "#" + name + renameType(att, name) + if ut, ok := att.Type.(UserType); ok { + att.Type = generatedUserType(ut, identity) } return att } @@ -300,7 +239,7 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE if resp.Headers.IsEmpty() && resp.Cookies.IsEmpty() { attr = DupAtt(attr) RemovePkgPath(attr) - renameType(attr, name, "Response") // Do not use ResponseBody as it could clash with name of element + renameType(attr, name) return attr } return &AttributeExpr{Type: Empty} @@ -320,28 +259,24 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE // 5. Build computed user type bodyAtt := body.Attribute() - if bodyAtt.Description == "" { - if t, ok := attr.Type.(UserType); ok { - bodyAtt.Description = t.Attribute().Description - } - } - if bodyAtt.Description == "" { + if t, ok := attr.Type.(UserType); ok { + // The generated body type describes the named Goa type after fields used + // by headers and cookies have been removed. Keep the type description + // separate from text that explains one method response. + typeAtt := t.Attribute() + bodyAtt.Description = typeAtt.Description + bodyAtt.authored = typeAtt.AuthoredAttribute() + } else if bodyAtt.Description == "" { bodyAtt.Description = attr.Description } - userType := &UserTypeExpr{ - AttributeExpr: bodyAtt, - TypeName: name, - UID: concat(svc.Name(), "#", name), - } + userType := NewGeneratedUserType(name, bodyAtt, identity) if t, ok := attr.Type.(UserType); ok { // Remember original type name for example to generate friendly - // OpenAPI specs. userType.AddMeta("name:original", t.Name()) copyOpenAPITypeMeta(t, userType) } - appendSuffix(userType.Attribute().Type, suffix) rt, isrt := attr.Type.(*ResultTypeExpr) if !isrt { return &AttributeExpr{ @@ -349,6 +284,7 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE Description: userType.Description, Validation: userType.Validation, Meta: attr.Meta, + authored: attr.AuthoredAttribute(), } } views := make([]*ViewExpr, len(rt.Views)) @@ -376,9 +312,25 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE Description: userType.Description, Validation: userType.Validation, Meta: attr.Meta, + authored: attr.AuthoredAttribute(), } } +// generatedUserType preserves result-type behavior while giving a computed +// transport type its own original declaration and repeatable example sequence. +func generatedUserType(typ UserType, identity ExampleIdentity) UserType { + generated := NewGeneratedUserType(typ.Name(), typ.Attribute(), identity) + if result, ok := typ.(*ResultTypeExpr); ok { + result.UserTypeExpr = generated + result.origin = nil + for _, view := range result.Views { + view.Parent = result + } + return result + } + return generated +} + // concat concatenates the given strings with "smart(?) casing". // The concatenation algorithm is: // @@ -449,19 +401,10 @@ func concat(strs ...string) string { return name } -func renameType(att *AttributeExpr, name, suffix string) { +func renameType(att *AttributeExpr, name string) { RemovePkgPath(att) - rt := att.Type - switch rtt := rt.(type) { - case UserType: - rtt.Rename(name) - appendSuffix(rtt.Attribute().Type, suffix) - case *Object: - appendSuffix(rt, suffix) - case *Array: - appendSuffix(rt, suffix) - case *Map: - appendSuffix(rt, suffix) + if userType, ok := att.Type.(UserType); ok { + userType.Rename(name) } } @@ -478,14 +421,6 @@ func RemovePkgPath(attr *AttributeExpr) { } } -// appendSuffix recursively traverses the given data type and appends the given -// suffix to all the user type names. -func appendSuffix(dt DataType, suffix string) { - walk(dt, func(ut UserType) { - ut.Rename(ut.Name() + suffix) - }) -} - func removeAttributes(attr, sub *MappedAttributeExpr) { o := AsObject(sub.Type) for _, nat := range *o { @@ -539,17 +474,18 @@ func extendBodyAttribute(body *MappedAttributeExpr) { // walk traverses the given data type and invokes the given function for each // user type it finds including dt itself. func walk(dt DataType, do func(UserType)) { - walkrec(dt, do, make(map[string]struct{})) + walkrec(dt, do, make(map[UserType]struct{})) } -func walkrec(dt DataType, do func(UserType), seen map[string]struct{}) { +func walkrec(dt DataType, do func(UserType), seen map[UserType]struct{}) { switch dt := dt.(type) { case UserType: - if _, ok := seen[dt.ID()]; ok { + origin := dt.Origin() + if _, ok := seen[origin]; ok { return } + seen[origin] = struct{}{} do(dt) - seen[dt.ID()] = struct{}{} walkrec(dt.Attribute().Type, do, seen) case *Object: for _, nat := range *dt { diff --git a/expr/http_body_types_test.go b/expr/http_body_types_test.go index 7eeb9641a1..8c4d5426bf 100644 --- a/expr/http_body_types_test.go +++ b/expr/http_body_types_test.go @@ -1,9 +1,12 @@ +// This file verifies HTTP body graph rewrites visit independent declarations +// and terminate when recursive copies return to their authored origin. package expr import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestHTTPStreamingBodyValidation(t *testing.T) { @@ -69,3 +72,102 @@ func TestHTTPStreamingBodyValidation(t *testing.T) { }) } } + +func TestComputedBodyExamplesDistinguishHTTPAndJSONRPC(t *testing.T) { + service := &ServiceExpr{Name: "Service"} + method := &MethodExpr{Name: "Method", Service: service} + httpEndpoint := &HTTPEndpointExpr{ + MethodExpr: method, + Service: &HTTPServiceExpr{ServiceExpr: service}, + Body: &AttributeExpr{Type: &UserTypeExpr{ + TypeName: "HTTPBody", + AttributeExpr: &AttributeExpr{Type: &Object{ + {Name: "http", Attribute: &AttributeExpr{Type: String}}, + }}, + }}, + } + jsonRPCEndpoint := &HTTPEndpointExpr{ + MethodExpr: method, + Service: &HTTPServiceExpr{ServiceExpr: service}, + Meta: MetaExpr{"jsonrpc": {}}, + Body: &AttributeExpr{Type: &UserTypeExpr{ + TypeName: "JSONRPCBody", + AttributeExpr: &AttributeExpr{Type: &Object{ + {Name: "jsonrpc", Attribute: &AttributeExpr{Type: String}}, + }}, + }}, + } + httpBody := httpRequestBody(httpEndpoint) + jsonRPCBody := httpRequestBody(jsonRPCEndpoint) + require.NotEqual(t, httpBody.Type.(UserType).ID(), jsonRPCBody.Type.(UserType).ID()) + + cases := []struct { + Name string + First *HTTPEndpointExpr + FirstBody *AttributeExpr + Second *HTTPEndpointExpr + SecondBody *AttributeExpr + }{ + { + Name: "HTTP then JSON-RPC", + First: httpEndpoint, + FirstBody: httpBody, + Second: jsonRPCEndpoint, + SecondBody: jsonRPCBody, + }, + { + Name: "JSON-RPC then HTTP", + First: jsonRPCEndpoint, + FirstBody: jsonRPCBody, + Second: httpEndpoint, + SecondBody: httpBody, + }, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + generator := NewExampleGenerator(NewFakerRandomizerFactory("test")) + first := c.FirstBody.Example(generator.At(RequestBodyExampleIdentity(c.First))).(map[string]any) + second := c.SecondBody.Example(generator.At(RequestBodyExampleIdentity(c.Second))).(map[string]any) + + require.Contains(t, first, transportBodyField(c.First)) + require.NotContains(t, first, transportBodyField(c.Second)) + require.Contains(t, second, transportBodyField(c.Second)) + require.NotContains(t, second, transportBodyField(c.First)) + }) + } +} + +// transportBodyField returns the field unique to the endpoint's body mapping. +func transportBodyField(endpoint *HTTPEndpointExpr) string { + if endpoint.IsJSONRPC() { + return "jsonrpc" + } + return "http" +} + +func TestRemovePkgPathDistinguishesEqualUIDOrigins(t *testing.T) { + first := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{ + Type: &Object{}, + Meta: MetaExpr{"struct:pkg:path": {"first/types"}}, + }, + TypeName: "First", + UID: "shared", + } + second := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{ + Type: &Object{}, + Meta: MetaExpr{"struct:pkg:path": {"second/types"}}, + }, + TypeName: "Second", + UID: "shared", + } + root := &AttributeExpr{Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: first}}, + {Name: "second", Attribute: &AttributeExpr{Type: second}}, + }} + + RemovePkgPath(root) + require.NotContains(t, first.Attribute().Meta, "struct:pkg:path") + require.NotContains(t, second.Attribute().Meta, "struct:pkg:path") +} diff --git a/expr/http_endpoint.go b/expr/http_endpoint.go index 565d63bb8c..7795deb164 100644 --- a/expr/http_endpoint.go +++ b/expr/http_endpoint.go @@ -1,3 +1,5 @@ +// This file prepares, validates, and finalizes the HTTP transport contract for +// one service method. package expr import ( @@ -136,10 +138,10 @@ func (e *HTTPEndpointExpr) UsesSSE() bool { return e.SSE != nil && (e.MethodExpr.IsResultStreaming() || e.MethodExpr.HasMixedResults()) } -// UsesWebSocket returns true if the endpoint streams payloads or results over a -// WebSocket connection. +// UsesWebSocket returns true if an ordinary HTTP endpoint streams payloads or +// results over a WebSocket connection. func (e *HTTPEndpointExpr) UsesWebSocket() bool { - return e.MethodExpr.IsStreaming() && e.SSE == nil + return !e.IsJSONRPC() && e.MethodExpr.IsStreaming() && e.SSE == nil } // HasAbsoluteRoutes returns true if all the endpoint routes are absolute. @@ -228,15 +230,15 @@ func (e *HTTPEndpointExpr) Prepare() { // Inherit headers, cookies and params from parent service and API headers := NewEmptyMappedAttributeExpr() - headers.Merge(Root.API.HTTP.Headers) + headers.Merge(e.MethodExpr.Service.design.API.HTTP.Headers) headers.Merge(e.Service.Headers) cookies := NewEmptyMappedAttributeExpr() - cookies.Merge(Root.API.HTTP.Cookies) + cookies.Merge(e.MethodExpr.Service.design.API.HTTP.Cookies) cookies.Merge(e.Service.Cookies) params := NewEmptyMappedAttributeExpr() - params.Merge(Root.API.HTTP.Params) + params.Merge(e.MethodExpr.Service.design.API.HTTP.Params) params.Merge(e.Service.Params) if p := e.Service.Parent(); p != nil { @@ -308,8 +310,8 @@ func (e *HTTPEndpointExpr) Prepare() { if e.MethodExpr.Stream == ServerStreamKind && e.SSE == nil { if e.Service.SSE != nil { e.SSE = e.Service.SSE - } else if Root.API.HTTP.SSE != nil { - e.SSE = Root.API.HTTP.SSE + } else if e.MethodExpr.Service.design.API.HTTP.SSE != nil { + e.SSE = e.MethodExpr.Service.design.API.HTTP.SSE } } @@ -355,7 +357,7 @@ func (e *HTTPEndpointExpr) Prepare() { } } if !found { - for _, ae := range Root.API.HTTP.Errors { + for _, ae := range e.MethodExpr.Service.design.API.HTTP.Errors { if se.Name == ae.Name { e.HTTPErrors = append(e.HTTPErrors, ae.Dup()) break @@ -364,8 +366,7 @@ func (e *HTTPEndpointExpr) Prepare() { } } - // Make sure JSON-RPC HTTP verb is set to GET if the endpoint is a - // WebSocket endpoint + // WebSocket endpoints use GET for the HTTP upgrade request. if e.UsesWebSocket() && len(e.Routes) > 0 { e.Routes[0].Method = "GET" } @@ -389,7 +390,7 @@ func (e *HTTPEndpointExpr) Validate() error { // SkipRequestBodyEncodeDecode is not compatible with gRPC or WebSocket if e.SkipRequestBodyEncodeDecode { - if s := Root.API.GRPC.Service(e.Service.Name()); s != nil { + if s := e.MethodExpr.Service.design.API.GRPC.Service(e.Service.Name()); s != nil { if s.Endpoint(e.Name()) != nil { verr.Add(e, "Endpoint cannot use SkipRequestBodyEncodeDecode and define a gRPC transport.") } @@ -404,7 +405,7 @@ func (e *HTTPEndpointExpr) Validate() error { // SkipResponseBodyEncodeDecode is not compatible with gRPC or WebSocket. if e.SkipResponseBodyEncodeDecode { - if s := Root.API.GRPC.Service(e.Service.Name()); s != nil { + if s := e.MethodExpr.Service.design.API.GRPC.Service(e.Service.Name()); s != nil { if s.Endpoint(e.Name()) != nil { verr.Add(e, "Endpoint response cannot use SkipResponseBodyEncodeDecode and define a gRPC transport.") } @@ -422,6 +423,23 @@ func (e *HTTPEndpointExpr) Validate() error { } } + // A WebSocket client learns the result view from the connection handshake. + // Receiving a streamed payload starts that connection before the service can + // choose a result view, so the design must select the view in advance. + if e.UsesWebSocket() && e.MethodExpr.IsPayloadStreaming() && !e.MethodExpr.HasMixedResults() { + if result, ok := e.MethodExpr.Result.Type.(*ResultTypeExpr); ok { + viewCount := len(result.Views) + if result.View(DefaultView) == nil { + viewCount++ + } + _, selectedByMethod := e.MethodExpr.Result.Meta.Last(ViewMetaKey) + _, selectedByType := result.Meta.Last(ViewMetaKey) + if viewCount > 1 && !selectedByMethod && !selectedByType { + verr.Add(e, "Endpoint cannot choose a result view at runtime when the method defines StreamingPayload because the WebSocket connection starts before the result view is known. Select a view in Result or StreamingResult.") + } + } + } + // Validate streaming endpoints for SSE compatibility if e.MethodExpr.Stream == ServerStreamKind { if e.SSE != nil { @@ -436,9 +454,9 @@ func (e *HTTPEndpointExpr) Validate() error { // Validate mixed results configuration if e.MethodExpr.HasMixedResults() { - // Mixed results (different Result and StreamingResult types) requires SSE + // A separate streaming result requires SSE. if e.SSE == nil { - verr.Add(e, "Methods with both Result and StreamingResult defined with different types must use ServerSentEvents()") + verr.Add(e, "Methods with both Result and StreamingResult must use ServerSentEvents()") } // Cannot have bidirectional streaming with mixed results if e.MethodExpr.IsPayloadStreaming() { @@ -462,10 +480,17 @@ func (e *HTTPEndpointExpr) Validate() error { // JSON-RPC validation if e.IsJSONRPC() { - // JSON-RPC WebSocket endpoints with server streaming cannot have both Payload and StreamingPayload - if e.UsesWebSocket() && e.MethodExpr.Stream == ServerStreamKind { - if e.MethodExpr.Payload.Type != Empty && e.MethodExpr.StreamingPayload.Type != Empty { - verr.Add(e, "JSON-RPC WebSocket server streaming method %q cannot define both Payload and StreamingPayload. Use Payload for the request data", e.MethodExpr.Name) + if e.MethodExpr.HasMixedResults() { + verr.Add(e, "JSON-RPC method %q cannot define both Result and StreamingResult because its client stream cannot return a separate final result", e.MethodExpr.Name) + } + switch e.MethodExpr.Stream { + case ClientStreamKind: + verr.Add(e, "JSON-RPC method %q cannot use client streaming because one JSON-RPC request contains one params value", e.MethodExpr.Name) + case BidirectionalStreamKind: + verr.Add(e, "JSON-RPC method %q cannot use bidirectional streaming because one JSON-RPC request contains one params value", e.MethodExpr.Name) + case ServerStreamKind: + if e.SSE == nil { + verr.Add(e, "JSON-RPC method %q with a streaming result must use ServerSentEvents()", e.MethodExpr.Name) } } @@ -546,6 +571,14 @@ func (e *HTTPEndpointExpr) Validate() error { hasTags = true } if r.StatusCode < 400 { + if e.MethodExpr.IsStreaming() { + if !r.Headers.IsEmpty() { + verr.Add(r, "streaming success response cannot map result attributes to HTTP headers") + } + if !r.Cookies.IsEmpty() { + verr.Add(r, "streaming success response cannot map result attributes to HTTP cookies") + } + } if successResp && e.MethodExpr.Stream == ServerStreamKind { verr.Add(r, "At most one success response can be defined for a streaming endpoint.") if r.Body != nil && r.Body.Type == Empty { @@ -604,6 +637,7 @@ func (e *HTTPEndpointExpr) Validate() error { for _, er := range e.HTTPErrors { verr.Merge(er.Validate()) } + verr.Merge(e.validateErrorMappings()) // Validate definitions of params, headers and bodies against definition of payload var ( @@ -731,18 +765,16 @@ func (e *HTTPEndpointExpr) Validate() error { } body := httpRequestBody(e) + if e.MultipartRequest && body.Type == Empty { + verr.Add(e, "MultipartRequest requires a request body.") + } if e.SkipRequestBodyEncodeDecode && body.Type != Empty { verr.Add(e, "HTTP endpoint request body must be empty when using SkipRequestBodyEncodeDecode but not all method payload attributes are mapped to headers and params. Make sure to define Headers and Params as needed.") } - // For streaming endpoints, check if request body is allowed + // WebSocket upgrade requests cannot carry a request body. if e.MethodExpr.IsStreaming() && body.Type != Empty { - // SSE endpoints can have request bodies, but WebSocket endpoints cannot - // Refer WebSocket protocol - https://tools.ietf.org/html/rfc6455 - // Exception: JSON-RPC WebSocket endpoints can have payloads as they are sent - // as JSON-RPC messages after the WebSocket connection is established - _, isJSONRPC := e.MethodExpr.Meta["jsonrpc"] - if e.UsesWebSocket() && !isJSONRPC { + if e.UsesWebSocket() { verr.Add(e, "HTTP endpoint request body must be empty when the endpoint uses streaming. Payload attributes must be mapped to headers and/or params.") } } @@ -750,26 +782,46 @@ func (e *HTTPEndpointExpr) Validate() error { return verr } +// validateErrorMappings ensures inherited HTTP response policy describes the +// same concrete error value returned by the endpoint method. +func (e *HTTPEndpointExpr) validateErrorMappings() *eval.ValidationErrors { + verr := new(eval.ValidationErrors) + for _, mapping := range e.HTTPErrors { + mapped, owner := mapping.mappedError() + method := e.MethodExpr.Error(mapping.Name) + if mapped == nil || method == nil || equivalentErrorAttributes(mapped.AttributeExpr, method.AttributeExpr) { + continue + } + verr.Add( + mapping.Response, + `HTTP error mapping %q inherited from the %s uses error type %q, but method %q of service %q uses %q; both definitions must define the same error attribute; %s`, + mapping.Name, + owner, + mapped.Type.Name(), + e.MethodExpr.Name, + e.MethodExpr.Service.Name, + method.Type.Name(), + errorAttributeDifference(mapped.AttributeExpr, method.AttributeExpr), + ) + } + return verr +} + +// errorAttributeDifference names qualifier settings when they are the reason +// two reusable error definitions disagree. +func errorAttributeDifference(first, second *AttributeExpr) string { + if settings := differingErrorQualifierSettings(first, second); len(settings) > 0 { + return "the " + strings.Join(settings, ", ") + " setting differs" + } + return "their type, validations, defaults, or metadata differ" +} + // Finalize is run post DSL execution. It merges response definitions, creates // implicit endpoint parameters and initializes querystring parameters. It also // flattens the error responses and makes sure the error types are all user // types so that the response encoding code can properly use the type to infer // the response that it needs to build. func (e *HTTPEndpointExpr) Finalize() { - // For JSON-RPC WebSocket endpoints with server streaming and non-streaming payload, - // move the payload to streaming payload. This is because the payload is sent as - // JSON-RPC messages after the WebSocket connection is established, making it - // effectively a streaming payload from the transport perspective. - if _, isJSONRPC := e.MethodExpr.Meta["jsonrpc"]; isJSONRPC && e.UsesWebSocket() && e.MethodExpr.Stream == ServerStreamKind { - if e.MethodExpr.Payload.Type != Empty && e.MethodExpr.StreamingPayload.Type == Empty { - // Move payload to streaming payload - e.MethodExpr.StreamingPayload = e.MethodExpr.Payload - e.MethodExpr.Payload = &AttributeExpr{Type: Empty} - // Change stream kind to bidirectional since we now have both streaming payload and result - e.MethodExpr.Stream = BidirectionalStreamKind - } - } - // Compute security scheme attribute name and corresponding HTTP location requirements := EffectiveSecurityRequirements(e.MethodExpr.Requirements) if reqLen := len(requirements); reqLen > 0 { @@ -828,17 +880,6 @@ func (e *HTTPEndpointExpr) Finalize() { e.StreamingBody.Finalize() } - // For JSON-RPC, WebSocket handling is managed at the server level. - // Each endpoint is treated as a standard HTTP endpoint; the server is responsible - // for upgrading the connection, decoding incoming JSON-RPC requests, and dispatching - // them to the appropriate endpoint handlers. - if e.IsJSONRPC() { - if e.MethodExpr.IsPayloadStreaming() { - e.MethodExpr.Payload = e.MethodExpr.StreamingPayload - e.Body = e.StreamingBody - } - } - // Initialize responses parent, headers and body for _, r := range e.Responses { r.Finalize(e, e.MethodExpr.Result) @@ -1150,6 +1191,7 @@ func initAttrFromDesign(att, patt *AttributeExpr) { if patt == nil || patt.Type == Empty { return } + att.authored = patt.AuthoredAttribute() att.Type = patt.Type if att.Description == "" { att.Description = patt.Description @@ -1198,12 +1240,12 @@ func isEmpty(a *AttributeExpr) bool { // hasJSONRPCIDField returns true if an attribute or any of its nested attributes // has the "jsonrpc:id" meta tag, indicating it's designated as the JSON-RPC ID field. func hasJSONRPCIDField(attr *AttributeExpr) bool { - return hasJSONRPCIDFieldRec(attr, make(map[*AttributeExpr]struct{}), make(map[string]struct{})) + return hasJSONRPCIDFieldRec(attr, make(map[*AttributeExpr]struct{})) } // hasJSONRPCIDFieldRec walks the attribute graph looking for the jsonrpc:id meta // while guarding against cycles that may occur with recursive user types. -func hasJSONRPCIDFieldRec(attr *AttributeExpr, seen map[*AttributeExpr]struct{}, seenUT map[string]struct{}) bool { +func hasJSONRPCIDFieldRec(attr *AttributeExpr, seen map[*AttributeExpr]struct{}) bool { if attr == nil || attr.Type == Empty { return false } @@ -1222,7 +1264,7 @@ func hasJSONRPCIDFieldRec(attr *AttributeExpr, seen map[*AttributeExpr]struct{}, // For object types, check all nested attributes if obj := AsObject(attr.Type); obj != nil { for _, nat := range *obj { - if hasJSONRPCIDFieldRec(nat.Attribute, seen, seenUT) { + if hasJSONRPCIDFieldRec(nat.Attribute, seen) { return true } } @@ -1231,11 +1273,7 @@ func hasJSONRPCIDFieldRec(attr *AttributeExpr, seen map[*AttributeExpr]struct{}, // For user types, check the underlying attribute (guarding for recursion) if ut, ok := attr.Type.(UserType); ok { if ut != nil { - if _, ok := seenUT[ut.ID()]; ok { - return false - } - seenUT[ut.ID()] = struct{}{} - return hasJSONRPCIDFieldRec(ut.Attribute(), seen, seenUT) + return hasJSONRPCIDFieldRec(ut.Attribute(), seen) } } return false diff --git a/expr/http_endpoint_internal_test.go b/expr/http_endpoint_internal_test.go new file mode 100644 index 0000000000..140b4e9458 --- /dev/null +++ b/expr/http_endpoint_internal_test.go @@ -0,0 +1,31 @@ +// This file verifies recursive HTTP and JSON-RPC expression inspection across +// unrelated declarations that share a semantic identifier. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHasJSONRPCIDFieldDistinguishesEqualUIDOrigins(t *testing.T) { + first := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "First", + UID: "shared", + } + second := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{ + Type: String, + Meta: MetaExpr{"jsonrpc:id": {}}, + }, + TypeName: "Second", + UID: "shared", + } + root := &AttributeExpr{Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: first}}, + {Name: "second", Attribute: &AttributeExpr{Type: second}}, + }} + + require.True(t, hasJSONRPCIDField(root)) +} diff --git a/expr/http_endpoint_test.go b/expr/http_endpoint_test.go index b3931f8aa7..bc2c860b4e 100644 --- a/expr/http_endpoint_test.go +++ b/expr/http_endpoint_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/expr/testdata" @@ -176,6 +177,10 @@ service "Service" HTTP endpoint "Method": HTTP endpoint response body must be em DSL: testdata.EndpointPayloadMissingRequired, Error: `service "Service" HTTP endpoint "Method": The following HTTP request body attribute is required but the corresponding method payload attribute is not: nonreq. Use 'Required' to make the attribute required in the method payload as well.`, }, + "endpoint-multipart-without-body": { + DSL: testdata.EndpointMultipartWithoutBody, + Error: `service "Service" HTTP endpoint "Method": MultipartRequest requires a request body.`, + }, "streaming-endpoint-has-request-body": { DSL: testdata.StreamingEndpointRequestBody, Error: `service "Service" HTTP endpoint "MethodA": HTTP endpoint request body must be empty when the endpoint uses streaming. Payload attributes must be mapped to headers and/or params. @@ -210,6 +215,143 @@ service "Service" HTTP endpoint "MethodC": HTTP endpoint request body must be em } } +func TestHTTPWebSocketViewedResultValidation(t *testing.T) { + tests := []struct { + name string + collection bool + collectionView string + method func(*expr.ResultTypeExpr) + err string + }{ + { + name: "client stream with caller-selected view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result) + }, + err: `service "Service" HTTP endpoint "Method": Endpoint cannot choose a result view at runtime when the method defines StreamingPayload because the WebSocket connection starts before the result view is known. Select a view in Result or StreamingResult.`, + }, + { + name: "bidirectional stream with caller-selected view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(result) + }, + err: `service "Service" HTTP endpoint "Method": Endpoint cannot choose a result view at runtime when the method defines StreamingPayload because the WebSocket connection starts before the result view is known. Select a view in Result or StreamingResult.`, + }, + { + name: "client stream with caller-selected collection view", + collection: true, + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result) + }, + err: `service "Service" HTTP endpoint "Method": Endpoint cannot choose a result view at runtime when the method defines StreamingPayload because the WebSocket connection starts before the result view is known. Select a view in Result or StreamingResult.`, + }, + { + name: "client stream with fixed view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result, func() { + dsl.View("tiny") + }) + }, + }, + { + name: "bidirectional stream with fixed view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(result, func() { + dsl.View("tiny") + }) + }, + }, + { + name: "client stream with fixed collection view", + collection: true, + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result, func() { + dsl.View("tiny") + }) + }, + }, + { + name: "client stream with view fixed by collection type", + collection: true, + collectionView: "tiny", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result) + }, + }, + { + name: "server stream with caller-selected view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingResult(result) + }, + }, + { + name: "client stream without views", + method: func(*expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + }, + }, + { + name: "bidirectional stream without views", + method: func(*expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + design := httpWebSocketViewedResultDSL(test.collection, test.collectionView, test.method) + if test.err == "" { + expr.RunDSL(t, design) + return + } + err := expr.RunInvalidDSL(t, design) + if got := stripValidationLocations(err.Error()); got != test.err { + t.Errorf("got %q, expected %q", got, test.err) + } + }) + } +} + +// httpWebSocketViewedResultDSL defines a result with two response shapes and +// lets each test choose how the method streams it. +func httpWebSocketViewedResultDSL(collection bool, collectionView string, method func(*expr.ResultTypeExpr)) func() { + return func() { + result := dsl.ResultType("application/vnd.websocket-view", func() { + dsl.Attribute("name", dsl.String) + dsl.View("tiny", func() { + dsl.Attribute("name") + }) + }) + if collection { + if collectionView == "" { + result = dsl.CollectionOf(result) + } else { + result = dsl.CollectionOf(result, func() { + dsl.View(collectionView) + }) + } + } + dsl.Service("Service", func() { + dsl.Method("Method", func() { + method(result) + dsl.HTTP(func() { + dsl.GET("/") + }) + }) + }) + } +} + func TestHTTPEndpointParentRequired(t *testing.T) { root := expr.RunDSL(t, testdata.EndpointHasParent) svc := root.Service("Child") diff --git a/expr/http_error.go b/expr/http_error.go index 9f2ef4d1be..94113ed6ae 100644 --- a/expr/http_error.go +++ b/expr/http_error.go @@ -1,3 +1,5 @@ +// This file binds reusable HTTP error-response policy to the concrete error +// returned by each endpoint method. package expr import ( @@ -37,7 +39,7 @@ func (e *HTTPErrorExpr) Validate() *eval.ValidationErrors { verr.Add(e, "Error %#v does not match an error defined in the service", e.Name) } case *RootExpr: - if Root.Error(e.Name) == nil { + if p.Error(e.Name) == nil { verr.Add(e, "Error %#v does not match an error defined in the API", e.Name) } } @@ -49,7 +51,7 @@ func (e *HTTPErrorExpr) Validate() *eval.ValidationErrors { case *HTTPServiceExpr: ee = p.Error(e.Name) case *RootExpr: - ee = Root.Error(e.Name) + ee = p.Error(e.Name) } // validate headers @@ -87,16 +89,7 @@ func (e *HTTPErrorExpr) Validate() *eval.ValidationErrors { // Finalize looks up the corresponding method error expression. func (e *HTTPErrorExpr) Finalize(a *HTTPEndpointExpr) { - var ee *ErrorExpr - switch p := e.Response.Parent.(type) { - case *HTTPEndpointExpr: - ee = p.MethodExpr.Error(e.Name) - case *HTTPServiceExpr: - ee = p.Error(e.Name) - case *RootExpr: - ee = Root.Error(e.Name) - } - e.ErrorExpr = ee + e.ErrorExpr = a.MethodExpr.Error(e.Name) e.Response.Finalize(a, e.AttributeExpr) if e.Response.Body == nil { e.Response.Body = httpErrorResponseBody(a, e) @@ -119,6 +112,20 @@ func (e *HTTPErrorExpr) Finalize(a *HTTPEndpointExpr) { e.Response.ContentType = mt.Identifier } +// mappedError returns the error declaration that owns this reusable HTTP +// response policy before the policy is applied to an endpoint method. +func (e *HTTPErrorExpr) mappedError() (*ErrorExpr, string) { + switch parent := e.Response.Parent.(type) { + case *HTTPEndpointExpr: + return parent.MethodExpr.Error(e.Name), "method" + case *HTTPServiceExpr: + return parent.Error(e.Name), "service" + case *RootExpr: + return parent.Error(e.Name), "API" + } + return nil, "" +} + // Dup creates a copy of the error expression. func (e *HTTPErrorExpr) Dup() *HTTPErrorExpr { return &HTTPErrorExpr{ diff --git a/expr/http_file_server.go b/expr/http_file_server.go index 9ed969a98c..2d6d0fb3b1 100644 --- a/expr/http_file_server.go +++ b/expr/http_file_server.go @@ -57,7 +57,7 @@ func (f *HTTPFileServerExpr) Finalize() { if isAbs { p = current } else { - p = path.Join(Root.API.HTTP.Path, sp, current) + p = path.Join(f.Service.Root.Path, sp, current) } // Make sure request path starts with a "/" so codegen can rely on it. if !strings.HasPrefix(p, "/") { diff --git a/expr/http_response.go b/expr/http_response.go index a6bb093beb..684f69d6ba 100644 --- a/expr/http_response.go +++ b/expr/http_response.go @@ -348,7 +348,7 @@ func (r *HTTPResponseExpr) Dup() *HTTPResponseExpr { // attributes are mapped to special goa headers in the form of // "Goa-Attribute(-)". func (r *HTTPResponseExpr) mapUnmappedAttrs(svcAtt *AttributeExpr) { - if svcAtt.Type != ErrorResult { + if !IsErrorResult(svcAtt.Type) { return } diff --git a/expr/http_service.go b/expr/http_service.go index 1bd9ba63c7..6ff108ddb5 100644 --- a/expr/http_service.go +++ b/expr/http_service.go @@ -66,12 +66,7 @@ func (svc *HTTPServiceExpr) Description() string { // Error returns the error with the given name. func (svc *HTTPServiceExpr) Error(name string) *ErrorExpr { - for _, erro := range svc.ServiceExpr.Errors { - if erro.Name == name { - return erro - } - } - return Root.Error(name) + return svc.ServiceExpr.Error(name) } // Endpoint returns the service endpoint with the given name or nil if there @@ -280,57 +275,13 @@ func (svc *HTTPServiceExpr) validateErrors(verr *eval.ValidationErrors) { } } -// validateTransports validates transport compatibility and JSON-RPC constraints +// validateTransports validates JSON-RPC route constraints. func (svc *HTTPServiceExpr) validateTransports(verr *eval.ValidationErrors) { - var ( - hasPureHTTPWebSocket bool - hasJSONRPCWebSocket bool - ) - - // Analyze endpoints - for _, e := range svc.HTTPEndpoints { - if e.IsJSONRPC() { - if e.UsesWebSocket() { - hasJSONRPCWebSocket = true - } - } else if e.UsesWebSocket() { - hasPureHTTPWebSocket = true - } - } - - // Validate JSON-RPC and pure HTTP WebSocket mixing - if hasJSONRPCWebSocket && hasPureHTTPWebSocket { - verr.Add(svc, "Service cannot mix JSON-RPC WebSocket endpoints with pure HTTP WebSocket endpoints. JSON-RPC uses a single WebSocket connection for all methods, while pure HTTP WebSocket creates individual connections per endpoint.") - } - - // Validate JSON-RPC WebSocket constraints - if hasJSONRPCWebSocket { - svc.validateJSONRPCWebSocketConstraints(verr) - } - - // Validate JSON-RPC transport consistency if svc.ServiceExpr.Meta != nil && svc.ServiceExpr.Meta["jsonrpc:service"] != nil { - svc.validateJSONRPCTransportConsistency(verr) svc.validateJSONRPCRoutes(verr) } } -// validateJSONRPCWebSocketConstraints validates constraints for JSON-RPC WebSocket endpoints -func (svc *HTTPServiceExpr) validateJSONRPCWebSocketConstraints(verr *eval.ValidationErrors) { - for _, e := range svc.HTTPEndpoints { - name := e.MethodExpr.Name - if !e.Headers.IsEmpty() { - verr.Add(e, "JSON-RPC endpoint %q using WebSocket cannot have header mappings", name) - } - if !e.Cookies.IsEmpty() { - verr.Add(e, "JSON-RPC endpoint %q using WebSocket cannot have cookie mappings", name) - } - if !e.Params.IsEmpty() { - verr.Add(e, "JSON-RPC endpoint %q using WebSocket cannot have parameter mappings", name) - } - } -} - // Finalize initializes the path if no path is set in design. func (svc *HTTPServiceExpr) Finalize() { if len(svc.Paths) == 0 { @@ -367,18 +318,8 @@ func (svc *HTTPServiceExpr) prepareJSONRPCRoutes() { path = svc.Paths[0] } - method := "POST" // default - - // If using WebSocket, force GET - for _, e := range svc.HTTPEndpoints { - if e.IsJSONRPC() && e.UsesWebSocket() { - method = "GET" // WebSocket requires GET - break - } - } - route = &RouteExpr{ - Method: method, + Method: "POST", Path: path, } } @@ -395,46 +336,13 @@ func (svc *HTTPServiceExpr) prepareJSONRPCRoutes() { } } -// validateJSONRPCTransportConsistency validates JSON-RPC transport combinations. -// WebSocket cannot be mixed with other transports, but HTTP and SSE can coexist. -func (svc *HTTPServiceExpr) validateJSONRPCTransportConsistency(verr *eval.ValidationErrors) { - var hasWebSocket, hasSSE, hasRegular bool - - for _, e := range svc.HTTPEndpoints { - if e.IsJSONRPC() { - switch { - case e.UsesWebSocket(): - hasWebSocket = true - case e.UsesSSE(): - hasSSE = true - default: - hasRegular = true - } - } - } - - // WebSocket cannot be mixed with any other transport - if hasWebSocket && (hasSSE || hasRegular) { - verr.Add(svc, "JSON-RPC service %q cannot mix WebSocket with other transports (SSE or regular HTTP). WebSocket requires a single persistent connection for all methods.", svc.Name()) - } - // HTTP and SSE can be mixed - they both use POST requests and can share the same endpoint -} - -// validateJSONRPCRoutes validates that JSON-RPC routes use the correct HTTP method. +// validateJSONRPCRoutes checks that every JSON-RPC route uses POST. func (svc *HTTPServiceExpr) validateJSONRPCRoutes(verr *eval.ValidationErrors) { for _, e := range svc.HTTPEndpoints { if e.IsJSONRPC() { for _, r := range e.Routes { - // WebSocket requires GET - if e.UsesWebSocket() { - if r.Method != "GET" { - verr.Add(r, "JSON-RPC WebSocket endpoint must use GET method, got %q", r.Method) - } - } else { - // Regular JSON-RPC and SSE require POST - if r.Method != "POST" { - verr.Add(r, "JSON-RPC endpoint must use POST method, got %q", r.Method) - } + if r.Method != "POST" { + verr.Add(r, "JSON-RPC endpoint must use POST method, got %q", r.Method) } } } diff --git a/expr/http_service_test.go b/expr/http_service_test.go deleted file mode 100644 index 1fa5729c2d..0000000000 --- a/expr/http_service_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package expr_test - -import ( - "strings" - "testing" - - . "goa.design/goa/v3/dsl" - "goa.design/goa/v3/expr" -) - -func TestHTTPServiceValidate(t *testing.T) { - cases := []struct { - Name string - DSL func() - Error string - ContainsError string - }{ - {"valid jsonrpc websocket", validJSONRPCWebSocketDSL, "", ""}, - {"jsonrpc websocket with headers", jsonrpcWebSocketWithHeadersDSL, "", `JSON-RPC endpoint "method" using WebSocket cannot have header mappings`}, - {"jsonrpc websocket with cookies", jsonrpcWebSocketWithCookiesDSL, "", `JSON-RPC endpoint "method" using WebSocket cannot have cookie mappings`}, - {"jsonrpc websocket with params", jsonrpcWebSocketWithParamsDSL, "", `JSON-RPC endpoint "method" using WebSocket cannot have parameter mappings`}, - {"jsonrpc websocket with all mappings", jsonrpcWebSocketWithAllMappingsDSL, "", `JSON-RPC endpoint "method" using WebSocket cannot have header mappings`}, - } - - for _, tc := range cases { - t.Run(tc.Name, func(t *testing.T) { - if tc.Error == "" && tc.ContainsError == "" { - expr.RunDSL(t, tc.DSL) - } else { - err := expr.RunInvalidDSL(t, tc.DSL) - if tc.Error != "" { - if err.Error() != tc.Error { - t.Errorf("got error %q, expected %q", err.Error(), tc.Error) - } - } else if tc.ContainsError != "" { - if !strings.Contains(err.Error(), tc.ContainsError) { - t.Errorf("error %q does not contain expected substring %q", err.Error(), tc.ContainsError) - } - } - } - }) - } -} - -// Test DSL functions - -var validJSONRPCWebSocketDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() {}) - }) - }) -} - -var jsonrpcWebSocketWithHeadersDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() { - Headers(func() { - Header("X-API-Version", String) - }) - }) - }) - }) -} - -var jsonrpcWebSocketWithCookiesDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() { - Cookie("session", String) - }) - }) - }) -} - -var jsonrpcWebSocketWithParamsDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() { - Params(func() { - Param("id", String) - }) - }) - }) - }) -} - -var jsonrpcWebSocketWithAllMappingsDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() { - Headers(func() { - Header("X-API-Version", String) - }) - Cookie("session", String) - Params(func() { - Param("id", String) - }) - }) - }) - }) -} diff --git a/expr/interceptor.go b/expr/interceptor.go index 1451c967b9..5a0f483b94 100644 --- a/expr/interceptor.go +++ b/expr/interceptor.go @@ -119,12 +119,12 @@ func (i *InterceptorExpr) validate(m *MethodExpr) *eval.ValidationErrors { if !m.IsResultStreaming() { verr.Add(m, "interceptor %q cannot be applied because the method result is not streaming", i.Name) } else { - if !IsObject(m.Result.Type) { + if !IsObject(m.StreamingResult.Type) { verr.Add(m, "interceptor %q cannot be applied because the method result is not an object", i.Name) } else { - result := DupAtt(m.Result) - if m.Result.Bases != nil { - for _, base := range m.Result.Bases { + result := DupAtt(m.StreamingResult) + if m.StreamingResult.Bases != nil { + for _, base := range m.StreamingResult.Bases { if ut, ok := base.(UserType); ok { result.Merge(ut.Attribute()) } diff --git a/expr/interceptor_test.go b/expr/interceptor_test.go index 08e7b2f6cc..37ea6a83d3 100644 --- a/expr/interceptor_test.go +++ b/expr/interceptor_test.go @@ -70,6 +70,14 @@ func TestInterceptorExpr_Validate(t *testing.T) { m.StreamingPayload = &AttributeExpr{Type: ut} }), }, + "streaming-result-distinct-from-result": { + intercept: makeInterceptor(t, withReadStreamingResult(t, namedAttr(t, "event"))), + method: makeMethod(t, func(m *MethodExpr) { + m.Stream = ServerStreamKind + m.Result = &AttributeExpr{Type: &Object{namedAttr(t, "summary")}} + m.StreamingResult = &AttributeExpr{Type: &Object{namedAttr(t, "event")}} + }), + }, "invalid-payload-not-object": { intercept: makeInterceptor(t, withReadPayload(t, namedAttr(t, "foo"))), method: makeMethod(t, func(m *MethodExpr) { diff --git a/expr/jsonrpc_stream_contract_test.go b/expr/jsonrpc_stream_contract_test.go new file mode 100644 index 0000000000..bf7e83332a --- /dev/null +++ b/expr/jsonrpc_stream_contract_test.go @@ -0,0 +1,122 @@ +// This file checks which Goa stream shapes JSON-RPC can represent. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestJSONRPCStreamContract checks the request and response stream shapes that +// JSON-RPC can carry over HTTP and server-sent events. +func TestJSONRPCStreamContract(t *testing.T) { + valid := []struct { + name string + method func() + }{ + { + name: "one request and one response", + method: func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }, + }, + { + name: "server stream over server sent events", + method: func() { + dsl.Payload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }, + }, + } + for _, test := range valid { + t.Run(test.name, func(t *testing.T) { + expr.RunDSL(t, jsonRPCStreamDSL(test.method)) + }) + } + + invalid := []struct { + name string + method func() + wantErr string + }{ + { + name: "client stream", + method: func() { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }, + wantErr: `JSON-RPC method "stream" cannot use client streaming because one JSON-RPC request contains one params value`, + }, + { + name: "bidirectional stream", + method: func() { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() {}) + }, + wantErr: `JSON-RPC method "stream" cannot use bidirectional streaming because one JSON-RPC request contains one params value`, + }, + { + name: "server stream without server sent events", + method: func() { + dsl.Payload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() {}) + }, + wantErr: `JSON-RPC method "stream" with a streaming result must use ServerSentEvents()`, + }, + { + name: "synchronous and streaming results", + method: func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.Int) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }, + wantErr: `JSON-RPC method "stream" cannot define both Result and StreamingResult because its client stream cannot return a separate final result`, + }, + { + name: "matching synchronous and streaming results", + method: func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }, + wantErr: `JSON-RPC method "stream" cannot define both Result and StreamingResult because its client stream cannot return a separate final result`, + }, + } + for _, test := range invalid { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, jsonRPCStreamDSL(test.method)) + require.Contains(t, err.Error(), test.wantErr) + }) + } +} + +// jsonRPCStreamDSL exposes one method through the shared JSON-RPC HTTP route. +func jsonRPCStreamDSL(method func()) func() { + return func() { + dsl.Service("streamer", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("stream", func() { + method() + }) + }) + } +} diff --git a/expr/jsonrpc_validation_test.go b/expr/jsonrpc_validation_test.go deleted file mode 100644 index 77201bf1eb..0000000000 --- a/expr/jsonrpc_validation_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package expr_test - -import ( - "errors" - "testing" - - "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" -) - -func TestJSONRPCTransportConsistency(t *testing.T) { - cases := []struct { - Name string - Setup func() *expr.HTTPServiceExpr - WantErr bool - ErrorMsg string - }{ - { - Name: "valid HTTP and SSE mix", - Setup: func() *expr.HTTPServiceExpr { - service := &expr.ServiceExpr{ - Name: "TestService", - Meta: expr.MetaExpr{"jsonrpc:service": []string{}}, - } - - httpService := &expr.HTTPServiceExpr{ - ServiceExpr: service, - Root: &expr.HTTPExpr{}, - } - - // Regular HTTP method - m1 := &expr.MethodExpr{ - Name: "GetUser", - Service: service, - Payload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - } - e1 := &expr.HTTPEndpointExpr{ - MethodExpr: m1, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - } - - // SSE streaming method - m2 := &expr.MethodExpr{ - Name: "WatchUsers", - Service: service, - Payload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - Stream: expr.ServerStreamKind, - } - e2 := &expr.HTTPEndpointExpr{ - MethodExpr: m2, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - SSE: &expr.HTTPSSEExpr{}, - } - - httpService.HTTPEndpoints = []*expr.HTTPEndpointExpr{e1, e2} - return httpService - }, - WantErr: false, - }, - { - Name: "invalid WebSocket and HTTP mix", - Setup: func() *expr.HTTPServiceExpr { - service := &expr.ServiceExpr{ - Name: "TestService", - Meta: expr.MetaExpr{"jsonrpc:service": []string{}}, - } - - httpService := &expr.HTTPServiceExpr{ - ServiceExpr: service, - Root: &expr.HTTPExpr{}, - } - - // WebSocket streaming method - m1 := &expr.MethodExpr{ - Name: "Stream", - Service: service, - StreamingPayload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - Stream: expr.BidirectionalStreamKind, - } - e1 := &expr.HTTPEndpointExpr{ - MethodExpr: m1, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - } - - // Regular HTTP method - m2 := &expr.MethodExpr{ - Name: "Get", - Service: service, - Payload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - } - e2 := &expr.HTTPEndpointExpr{ - MethodExpr: m2, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - } - - httpService.HTTPEndpoints = []*expr.HTTPEndpointExpr{e1, e2} - return httpService - }, - WantErr: true, - ErrorMsg: "cannot mix WebSocket with other transports", - }, - { - Name: "invalid WebSocket and SSE mix", - Setup: func() *expr.HTTPServiceExpr { - service := &expr.ServiceExpr{ - Name: "TestService", - Meta: expr.MetaExpr{"jsonrpc:service": []string{}}, - } - - httpService := &expr.HTTPServiceExpr{ - ServiceExpr: service, - Root: &expr.HTTPExpr{}, - } - - // WebSocket streaming method - m1 := &expr.MethodExpr{ - Name: "Stream", - Service: service, - StreamingPayload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - Stream: expr.BidirectionalStreamKind, - } - e1 := &expr.HTTPEndpointExpr{ - MethodExpr: m1, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - } - - // SSE streaming method - m2 := &expr.MethodExpr{ - Name: "Watch", - Service: service, - Payload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - Stream: expr.ServerStreamKind, - } - e2 := &expr.HTTPEndpointExpr{ - MethodExpr: m2, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - SSE: &expr.HTTPSSEExpr{}, - } - - httpService.HTTPEndpoints = []*expr.HTTPEndpointExpr{e1, e2} - return httpService - }, - WantErr: true, - ErrorMsg: "cannot mix WebSocket with other transports", - }, - } - - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - svc := c.Setup() - err := svc.Validate() - - if c.WantErr { - if err == nil { - t.Errorf("expected error containing %q but got none", c.ErrorMsg) - } else if !containsStr(err.Error(), c.ErrorMsg) { - t.Errorf("expected error containing %q but got %q", c.ErrorMsg, err.Error()) - } - } else { - if err != nil { - // Check if it's a ValidationErrors with no actual errors - var verr *eval.ValidationErrors - if errors.As(err, &verr) && len(verr.Errors) == 0 { - // Empty validation errors, ignore - } else { - t.Logf("Error type: %T", err) - t.Errorf("unexpected error: %v", err) - } - } - } - }) - } -} - -func containsStr(s, substr string) bool { - if len(s) < len(substr) { - return false - } - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/expr/method.go b/expr/method.go index 8b00d53ff5..83ed3d8435 100644 --- a/expr/method.go +++ b/expr/method.go @@ -1,3 +1,5 @@ +// This file defines service methods and finalizes their payload, result, +// streaming, error, security, and interceptor contracts. package expr import ( @@ -49,9 +51,8 @@ type ( // StreamingPayload is the payload sent across the stream. StreamingPayload *AttributeExpr // StreamingResult is the result sent across the stream when using SSE. - // When both Result and StreamingResult are defined with different types, - // the method supports content negotiation between standard HTTP responses - // (using Result) and SSE streams (using StreamingResult). + // When Result and StreamingResult are both defined, the method supports + // normal HTTP responses using Result and SSE streams using StreamingResult. StreamingResult *AttributeExpr } ) @@ -149,8 +150,8 @@ func (m *MethodExpr) validateRequirements() *eval.ValidationErrors { requirements = m.Requirements case len(m.Service.Requirements) > 0: requirements = m.Service.Requirements - case len(Root.API.Requirements) > 0: - requirements = Root.API.Requirements + case len(m.Service.design.API.Requirements) > 0: + requirements = m.Service.design.API.Requirements } var ( hasBasicAuth bool @@ -276,11 +277,19 @@ func (m *MethodExpr) validateErrors() *eval.ValidationErrors { // validateInterceptors validates the method interceptors. func (m *MethodExpr) validateInterceptors() *eval.ValidationErrors { verr := new(eval.ValidationErrors) - m.ClientInterceptors = mergeInterceptors(m.ClientInterceptors, m.Service.ClientInterceptors, Root.API.ClientInterceptors) + m.ClientInterceptors = mergeInterceptors( + m.ClientInterceptors, + m.Service.ClientInterceptors, + m.Service.design.API.ClientInterceptors, + ) for _, i := range m.ClientInterceptors { verr.Merge(i.validate(m)) } - m.ServerInterceptors = mergeInterceptors(m.ServerInterceptors, m.Service.ServerInterceptors, Root.API.ServerInterceptors) + m.ServerInterceptors = mergeInterceptors( + m.ServerInterceptors, + m.Service.ServerInterceptors, + m.Service.design.API.ServerInterceptors, + ) for _, i := range m.ServerInterceptors { verr.Merge(i.validate(m)) } @@ -400,6 +409,10 @@ func (m *MethodExpr) Finalize() { } } for _, e := range m.Errors { + if _, authored := e.Type.(UserType); !authored { + e.finalizeMethodType(m) + continue + } e.Finalize() } @@ -411,8 +424,8 @@ func (m *MethodExpr) Finalize() { if len(m.Requirements) == 0 { if len(m.Service.Requirements) > 0 { m.Requirements = copyReqs(m.Service.Requirements) - } else if len(Root.API.Requirements) > 0 { - m.Requirements = copyReqs(Root.API.Requirements) + } else if len(m.Service.design.API.Requirements) > 0 { + m.Requirements = copyReqs(m.Service.design.API.Requirements) } } } @@ -432,8 +445,8 @@ func (m *MethodExpr) IsResultStreaming() bool { return m.Stream == ServerStreamKind || m.Stream == BidirectionalStreamKind } -// HasMixedResults returns true if the method has both Result and StreamingResult -// defined with different types, indicating support for content negotiation. +// HasMixedResults returns true if the method defines Result and StreamingResult +// separately so HTTP clients can choose a normal response or an SSE stream. func (m *MethodExpr) HasMixedResults() bool { return m.Result != nil && m.StreamingResult != nil && m.Result != m.StreamingResult } diff --git a/expr/method_test.go b/expr/method_test.go index 213e574ea0..1207c3b91b 100644 --- a/expr/method_test.go +++ b/expr/method_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/expr/testdata" ) @@ -80,11 +81,6 @@ func TestMethodExprFinalizeInheritsBearerFormat(t *testing.T) { }, } - root := expr.Root - t.Cleanup(func() { - expr.Root = root - }) - for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { scheme := &expr.SchemeExpr{ @@ -96,7 +92,10 @@ func TestMethodExprFinalizeInheritsBearerFormat(t *testing.T) { } req := &expr.SecurityExpr{Schemes: []*expr.SchemeExpr{scheme}} api, service := tc.setup(req) - expr.Root = &expr.RootExpr{API: api} + designAPI := expr.NewAPIExpr("test", func() {}) + designAPI.Requirements = api.Requirements + design := &expr.RootExpr{API: designAPI, Services: []*expr.ServiceExpr{service}} + design.WalkSets(func(eval.ExpressionSet) {}) method := &expr.MethodExpr{ Name: "Method", @@ -114,27 +113,22 @@ func TestMethodExprFinalizeInheritsBearerFormat(t *testing.T) { } func TestMethodExprFinalizePreservesNoSecurityMarker(t *testing.T) { - root := expr.Root - t.Cleanup(func() { - expr.Root = root - }) - - expr.Root = &expr.RootExpr{ - API: &expr.APIExpr{ - Requirements: []*expr.SecurityExpr{{ - Schemes: []*expr.SchemeExpr{{ - Kind: expr.JWTKind, - SchemeName: "jwt", - }}, - }}, - }, - } + service := &expr.ServiceExpr{Name: "Service"} + api := expr.NewAPIExpr("test", func() {}) + api.Requirements = []*expr.SecurityExpr{{ + Schemes: []*expr.SchemeExpr{{ + Kind: expr.JWTKind, + SchemeName: "jwt", + }}, + }} + design := &expr.RootExpr{API: api, Services: []*expr.ServiceExpr{service}} + design.WalkSets(func(eval.ExpressionSet) {}) method := &expr.MethodExpr{ Name: "Health", Requirements: []*expr.SecurityExpr{{ Schemes: []*expr.SchemeExpr{{Kind: expr.NoKind}}, }}, - Service: &expr.ServiceExpr{Name: "Service"}, + Service: service, } method.Finalize() @@ -180,14 +174,17 @@ func TestMethodExprError(t *testing.T) { }, } - expr.Root.Errors = []*expr.ErrorExpr{ - errorBaz, - } s := expr.ServiceExpr{ Errors: []*expr.ErrorExpr{ errorBar, }, } + design := &expr.RootExpr{ + API: expr.NewAPIExpr("test", func() {}), + Errors: []*expr.ErrorExpr{errorBaz}, + Services: []*expr.ServiceExpr{&s}, + } + design.WalkSets(func(eval.ExpressionSet) {}) m := expr.MethodExpr{ Errors: []*expr.ErrorExpr{ errorFoo, diff --git a/expr/project_test.go b/expr/project_test.go index dd663a88e1..0aac79f6a9 100644 --- a/expr/project_test.go +++ b/expr/project_test.go @@ -1,3 +1,5 @@ +// This file verifies result-type projections preserve view shape, field +// metadata, recursion, and synthesized example ownership. package expr import ( @@ -10,7 +12,12 @@ import ( ) var ( - testrand = NewRandom("test") + testrand = NewExampleGenerator(NewFakerRandomizerFactory("test")).At( + MethodPayloadExampleIdentity(&MethodExpr{ + Name: "project", + Service: &ServiceExpr{Name: "test"}, + }), + ) simpleResult = resultType("a", String, "b", Int, view("default", "a", String, "b", Int), view("link", "a", String)) simpleResultDefault = resultType("a", String, "b", Int) @@ -82,6 +89,21 @@ func TestProject(t *testing.T) { } } +func TestProjectPreservesGeneratedExampleIdentity(t *testing.T) { + source := resultType("value", String, view("default", "value", String)) + owner := MethodResultExampleIdentity(&MethodExpr{ + Name: "read", + Service: &ServiceExpr{Name: "values"}, + }) + source.UserTypeExpr = NewGeneratedUserType(source.TypeName, source.AttributeExpr, owner) + + projected, err := Project(source, DefaultView) + require.NoError(t, err) + projectedOwner, ok := GeneratedUserTypeExampleIdentity(projected) + require.True(t, ok) + require.Equal(t, owner, projectedOwner) +} + // TestProjectDoesNotAliasFieldAttributes verifies that fields sharing a type // share the projected type but never the AttributeExpr wrapping it, so that // per-field metadata such as descriptions does not leak across fields. diff --git a/expr/random.go b/expr/random.go index 5555883cd8..b25809540b 100644 --- a/expr/random.go +++ b/expr/random.go @@ -1,3 +1,6 @@ +// This file creates the repeatable example values used by one code generation +// run. Each ExampleGenerator has its own sequence of values and shares only the +// map used while building recursive types. package expr import ( @@ -7,172 +10,126 @@ import ( "math/rand" "net" "strings" - "sync" "github.com/manveru/faker" ) -// Randomizer generates consistent random values of different types given a seed. -// -// The random values should be consistent in that given the same seed the same -// random values get generated. -// -// Setting the randomizer to nil disables example generation. -type Randomizer interface { - // ArrayLength decides how long an example array will be - ArrayLength() int - // Int generates an integer example - Int() int - // Int32 generates an int32 example - Int32() int32 - // Int64 generates an int64 example - Int64() int64 - // String generates a string example - String() string - // Bool generates a bool example - Bool() bool - // Float32 generates a float32 example - Float32() float32 - // Float64 generates a float64 example - Float64() float64 - // UInt generates a uint example - UInt() uint - // UInt32 generates a uint example - UInt32() uint32 - // UInt64 generates a uint example - UInt64() uint64 - // Name generates a human name example - Name() string - // Email generates an example email address - Email() string - // Hostname generates an example hostname - Hostname() string - // IPv4Address generates an example IPv4 address - IPv4Address() net.IP - // IPv6Address generates an example IPv6 address - IPv6Address() net.IP - // URL generates an example URL - URL() string - // Characters generates a n-character string example - Characters(n int) string - // UUID generates a random v4 UUID - UUID() string -} - -// NewRandom returns a random value generator seeded from the given string -// value, using the faker library to generate random but realistic values. -func NewRandom(seed string) *ExampleGenerator { - return &ExampleGenerator{ - Randomizer: NewFakerRandomizer(seed), - seed: seed, +type ( + // Randomizer produces the primitive values used in generated examples. Two + // Randomizer values created with the same settings and equal ExampleIdentity + // keys must produce the same sequence. + Randomizer interface { + // ArrayLength decides how long an example array will be. + ArrayLength() int + // Int generates an integer example. + Int() int + // Int32 generates an int32 example. + Int32() int32 + // Int64 generates an int64 example. + Int64() int64 + // String generates a string example. + String() string + // Bool generates a bool example. + Bool() bool + // Float32 generates a float32 example. + Float32() float32 + // Float64 generates a float64 example. + Float64() float64 + // UInt generates a uint example. + UInt() uint + // UInt32 generates a uint32 example. + UInt32() uint32 + // UInt64 generates a uint64 example. + UInt64() uint64 + // Name generates a human name example. + Name() string + // Email generates an email address example. + Email() string + // Hostname generates a hostname example. + Hostname() string + // IPv4Address generates an IPv4 address example. + IPv4Address() net.IP + // IPv6Address generates an IPv6 address example. + IPv6Address() net.IP + // URL generates a URL example. + URL() string + // Characters generates a string containing n characters. + Characters(n int) string + // UUID generates a random version 4 UUID. + UUID() string } -} -// ExampleGenerator generates examples from a value stream seeded by a design -// identity. Example computations derive child generators at stable design -// boundaries (user type IDs, object field names, array indices) via Derived -// so that an example is a pure function of the design: it does not change -// when unrelated parts of the design change or when code generators evaluate -// attributes in a different order. -type ExampleGenerator struct { - Randomizer - // seed identifies the design element this generator draws values for; - // generators derived from it extend the seed via Derived. It is empty - // for generators built around a caller-supplied Randomizer, which - // cannot re-seed and therefore never derive. - seed string - // root points to the generator this one was derived from so that all - // derived generators share the root's seen cache. It is nil on roots. - root *ExampleGenerator - seen map[string]*any - mu sync.RWMutex -} - -// Derived returns a generator whose value stream is seeded from this -// generator's seed extended with the given identity, independent of how many -// values were drawn so far. Derived generators share the root generator's -// seen values so a user type keeps a single example wherever it appears. -// Generators that cannot re-seed (disabled example generation or a -// caller-supplied Randomizer) return themselves. -func (r *ExampleGenerator) Derived(id string) *ExampleGenerator { - return r.reseeded(r.seed + "/" + id) -} - -// Rebased returns a generator whose value stream is seeded from the root -// design seed and the given absolute identity, discarding the current -// derivation path. It anchors examples of design elements that own a global -// identity — user type IDs in particular — so the computed value is the same -// no matter where in the design the element is reached from. Generators that -// cannot re-seed (disabled example generation or a caller-supplied -// Randomizer) return themselves. -func (r *ExampleGenerator) Rebased(id string) *ExampleGenerator { - return r.reseeded(r.store().seed + ":" + id) -} - -// PreviouslySeen returns the previously seen value for a given ID -func (r *ExampleGenerator) PreviouslySeen(typeID string) (*any, bool) { - s := r.store() - s.mu.RLock() - defer s.mu.RUnlock() - if s.seen == nil { - return nil, false + // RandomizerFactory stores settings used to create Randomizer values. + // NewRandomizer must return a new Randomizer on every call. Its + // ExampleIdentity argument selects a repeatable sequence without sharing + // values already consumed by another call. + RandomizerFactory interface { + // NewRandomizer creates an independent value sequence for the supplied + // ExampleIdentity. + NewRandomizer(identity ExampleIdentity) Randomizer } - val, haveSeen := s.seen[typeID] - return val, haveSeen -} -// HaveSeen stores the seen value in the randomizer, for reuse later -func (r *ExampleGenerator) HaveSeen(typeID string, val *any) { - s := r.store() - s.mu.Lock() - defer s.mu.Unlock() - if s.seen == nil { - s.seen = make(map[string]*any) + // exampleRandomizer lets ExampleGenerator expose Randomizer methods without + // exposing its stored Randomizer field. + exampleRandomizer interface { + Randomizer } - s.seen[typeID] = val -} + // ExampleGenerator builds examples from one value sequence. Child generators + // use separate repeatable keys for fields and collection entries, and share + // the map of values currently being built so recursive types can stop. One + // planning thread uses each generator; concurrent runs use separate values. + ExampleGenerator struct { + exampleRandomizer + factory RandomizerFactory + identity ExampleIdentity + // root points to the first generator so child generators share its map of + // values currently being built. It is nil on the first generator. + root *ExampleGenerator + seen map[UserType]*any + } -// Field returns a generator anchored to the identity of the named field of -// the given parent attribute: the parent type identity extended with the -// field name when the parent is a user type, the field name alone otherwise. -// Code generators use it when they compute the example of one element -// extracted from a payload or result (transport params, headers, cookies, -// metadata) so the standalone example matches the corresponding field value -// in the parent type's composite example and stays stable across generator -// changes. -func (r *ExampleGenerator) Field(parent *AttributeExpr, name string) *ExampleGenerator { - if ut, ok := parent.Type.(UserType); ok { - return r.Rebased(ut.ID()).Derived(name) + // FakerRandomizer produces repeatable example values with the faker library. + FakerRandomizer struct { + // Seed is the input used to create this value sequence. + Seed string + faker *faker.Faker + rand *rand.Rand } - return r.Rebased(name) -} -// store returns the generator owning the seen cache and the root design -// seed: the generator this one was derived from, or the generator itself -// when it is a root. -func (r *ExampleGenerator) store() *ExampleGenerator { - if r.root != nil { - return r.root + // DeterministicRandomizer returns the same fixed value from every method. + DeterministicRandomizer struct{} + + // fakerRandomizerFactory stores the seed configured by the API DSL. + fakerRandomizerFactory struct { + seed string } - return r + + // deterministicRandomizerFactory needs no settings. + deterministicRandomizerFactory struct{} +) + +// NewExampleGenerator returns a generator with no selected example sequence and +// no values currently being built. Call At with an ExampleIdentity before +// requesting a value. +func NewExampleGenerator(factory RandomizerFactory) *ExampleGenerator { + return &ExampleGenerator{factory: factory} } -// reseeded returns a generator drawing from a fresh value stream seeded with -// the given seed and sharing this generator's root state. -func (r *ExampleGenerator) reseeded(seed string) *ExampleGenerator { - if r.Randomizer == nil || r.store().seed == "" { - return r - } - return &ExampleGenerator{ - Randomizer: NewFakerRandomizer(seed), - seed: seed, - root: r.store(), - } +// NewFakerRandomizerFactory returns settings that create independent faker +// value sequences from seed. +func NewFakerRandomizerFactory(seed string) RandomizerFactory { + return fakerRandomizerFactory{seed: seed} } -// NewFakerRandomizer creates a randomizer that uses the faker library to -// generate fake but reasonable values. +// NewDeterministicRandomizerFactory returns settings that create independent +// Randomizer values whose methods return fixed values. +func NewDeterministicRandomizerFactory() RandomizerFactory { + return deterministicRandomizerFactory{} +} + +// NewFakerRandomizer returns a repeatable faker value sequence created from +// seed. func NewFakerRandomizer(seed string) Randomizer { hasher := md5.New() hasher.Write([]byte(seed)) @@ -192,64 +149,162 @@ func NewFakerRandomizer(seed string) Randomizer { } } -// FakerRandomizer implements the Random interface, using the Faker library. -type FakerRandomizer struct { - Seed string - faker *faker.Faker - rand *rand.Rand +// NewDeterministicRandomizer returns a value sequence whose methods return +// fixed values. +func NewDeterministicRandomizer() Randomizer { + return &DeterministicRandomizer{} } +// At returns a generator whose value sequence is selected by the supplied +// ExampleIdentity. The result shares the map of values currently being built +// in this run, but gets a new Randomizer with no consumed values. +func (r *ExampleGenerator) At(identity ExampleIdentity) *ExampleGenerator { + root := r.store() + if root.factory == nil { + return r + } + if identity.seed == "" { + panic("example identity is not initialized") + } + return &ExampleGenerator{ + exampleRandomizer: root.factory.NewRandomizer(identity), + factory: root.factory, + identity: identity, + root: root, + } +} + +// Member returns a generator whose repeatable sequence is selected by the +// current ExampleIdentity plus the named field. +func (r *ExampleGenerator) Member(name string) *ExampleGenerator { + if r.factory == nil { + return r + } + return r.structural(r.identity.Member(name)) +} + +// ArrayElement returns a generator whose repeatable sequence is selected by +// the current ExampleIdentity plus the array index. +func (r *ExampleGenerator) ArrayElement(index int) *ExampleGenerator { + if r.factory == nil { + return r + } + return r.structural(r.identity.ArrayElement(index)) +} + +// MapKey returns a generator whose repeatable sequence is selected by the +// current ExampleIdentity plus the map key index. +func (r *ExampleGenerator) MapKey(index int) *ExampleGenerator { + if r.factory == nil { + return r + } + return r.structural(r.identity.MapKey(index)) +} + +// MapValue returns a generator whose repeatable sequence is selected by the +// current ExampleIdentity plus the map value index. +func (r *ExampleGenerator) MapValue(index int) *ExampleGenerator { + if r.factory == nil { + return r + } + return r.structural(r.identity.MapValue(index)) +} + +// UnionMember returns a generator whose repeatable sequence is selected by the +// current ExampleIdentity plus the union branch name. +func (r *ExampleGenerator) UnionMember(name string) *ExampleGenerator { + if r.factory == nil { + return r + } + return r.structural(r.identity.UnionMember(name)) +} + +// ArrayLength returns a small positive array length. func (r *FakerRandomizer) ArrayLength() int { return r.Int()%3 + 2 } + +// Int returns the next int value. func (r *FakerRandomizer) Int() int { return r.rand.Int() } + +// Int32 returns the next int32 value. func (r *FakerRandomizer) Int32() int32 { return r.rand.Int31() } + +// Int64 returns the next int64 value. func (r *FakerRandomizer) Int64() int64 { return r.rand.Int63() } + +// String returns the next short sentence. func (r *FakerRandomizer) String() string { return r.faker.Sentence(2, false) } + +// Bool returns the next boolean value. func (r *FakerRandomizer) Bool() bool { return r.rand.Int()%2 == 0 } + +// Float32 returns the next float32 value. func (r *FakerRandomizer) Float32() float32 { return r.rand.Float32() } + +// Float64 returns the next float64 value. func (r *FakerRandomizer) Float64() float64 { return r.rand.Float64() } + +// UInt returns the next uint value. func (r *FakerRandomizer) UInt() uint { return uint(r.UInt64()) } + +// UInt32 returns the next uint32 value. func (r *FakerRandomizer) UInt32() uint32 { return r.rand.Uint32() } + +// UInt64 returns the next uint64 value. func (r *FakerRandomizer) UInt64() uint64 { return r.rand.Uint64() } + +// Email returns the next email address. func (r *FakerRandomizer) Email() string { return r.faker.Email() } + +// Hostname returns the next hostname. func (r *FakerRandomizer) Hostname() string { return r.faker.DomainName() + "." + r.faker.DomainSuffix() } + +// IPv4Address returns the next IPv4 address. func (r *FakerRandomizer) IPv4Address() net.IP { return r.faker.IPv4Address() } + +// IPv6Address returns the next IPv6 address. func (r *FakerRandomizer) IPv6Address() net.IP { return r.faker.IPv6Address() } + +// URL returns the next URL. func (r *FakerRandomizer) URL() string { return r.faker.URL() } + +// Characters returns the next string containing n characters. func (r *FakerRandomizer) Characters(n int) string { return r.faker.Characters(n) } + +// UUID returns the next random version 4 UUID. func (r *FakerRandomizer) UUID() string { uuid := make([]byte, 16) r.rand.Read(uuid) @@ -257,36 +312,121 @@ func (r *FakerRandomizer) UUID() string { uuid[8] = (uuid[8] & 0x3f) | 0x80 return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]) } + +// Name returns the next human name. func (r *FakerRandomizer) Name() string { return r.faker.Name() } -// NewDeterministicRandomizer builds a Randomizer that will return hard-coded -// values, removing all randomness from example generation. -func NewDeterministicRandomizer() Randomizer { - return &DeterministicRandomizer{} -} +// ArrayLength returns one. +func (DeterministicRandomizer) ArrayLength() int { return 1 } + +// Int returns one. +func (DeterministicRandomizer) Int() int { return 1 } + +// Int32 returns one. +func (DeterministicRandomizer) Int32() int32 { return 1 } + +// Int64 returns one. +func (DeterministicRandomizer) Int64() int64 { return 1 } + +// String returns a fixed string. +func (DeterministicRandomizer) String() string { return "abc123" } + +// Bool returns false. +func (DeterministicRandomizer) Bool() bool { return false } + +// Float32 returns one. +func (DeterministicRandomizer) Float32() float32 { return 1 } + +// Float64 returns one. +func (DeterministicRandomizer) Float64() float64 { return 1 } + +// UInt returns one. +func (DeterministicRandomizer) UInt() uint { return 1 } + +// UInt32 returns one. +func (DeterministicRandomizer) UInt32() uint32 { return 1 } + +// UInt64 returns one. +func (DeterministicRandomizer) UInt64() uint64 { return 1 } + +// Name returns a fixed human name. +func (DeterministicRandomizer) Name() string { return "Alice" } + +// Email returns a fixed email address. +func (DeterministicRandomizer) Email() string { return "alice@example.com" } + +// Hostname returns a fixed hostname. +func (DeterministicRandomizer) Hostname() string { return "example.com" } + +// IPv4Address returns the unspecified IPv4 address. +func (DeterministicRandomizer) IPv4Address() net.IP { return net.IPv4zero } + +// IPv6Address returns the unspecified IPv6 address. +func (DeterministicRandomizer) IPv6Address() net.IP { return net.IPv6zero } + +// URL returns a fixed URL. +func (DeterministicRandomizer) URL() string { return "https://example.com/foo" } -// DeterministicRandomizer returns hard-coded values, removing all randomness -// from example generation -type DeterministicRandomizer struct{} - -func (DeterministicRandomizer) ArrayLength() int { return 1 } -func (DeterministicRandomizer) Int() int { return 1 } -func (DeterministicRandomizer) Int32() int32 { return 1 } -func (DeterministicRandomizer) Int64() int64 { return 1 } -func (DeterministicRandomizer) String() string { return "abc123" } -func (DeterministicRandomizer) Bool() bool { return false } -func (DeterministicRandomizer) Float32() float32 { return 1 } -func (DeterministicRandomizer) Float64() float64 { return 1 } -func (DeterministicRandomizer) UInt() uint { return 1 } -func (DeterministicRandomizer) UInt32() uint32 { return 1 } -func (DeterministicRandomizer) UInt64() uint64 { return 1 } -func (DeterministicRandomizer) Name() string { return "Alice" } -func (DeterministicRandomizer) Email() string { return "alice@example.com" } -func (DeterministicRandomizer) Hostname() string { return "example.com" } -func (DeterministicRandomizer) IPv4Address() net.IP { return net.IPv4zero } -func (DeterministicRandomizer) IPv6Address() net.IP { return net.IPv6zero } -func (DeterministicRandomizer) URL() string { return "https://example.com/foo" } +// Characters returns n copies of "a". func (DeterministicRandomizer) Characters(n int) string { return strings.Repeat("a", n) } -func (DeterministicRandomizer) UUID() string { return "550e8400-e29b-41d4-a716-446655440000" } + +// UUID returns a fixed version 4 UUID. +func (DeterministicRandomizer) UUID() string { return "550e8400-e29b-41d4-a716-446655440000" } + +// NewRandomizer creates an independent faker value sequence selected by the +// supplied ExampleIdentity key. +func (f fakerRandomizerFactory) NewRandomizer(identity ExampleIdentity) Randomizer { + return NewFakerRandomizer(f.seed + identity.Seed()) +} + +// NewRandomizer creates an independent Randomizer whose methods return fixed +// values. The supplied ExampleIdentity does not change those values. +func (deterministicRandomizerFactory) NewRandomizer(ExampleIdentity) Randomizer { + return NewDeterministicRandomizer() +} + +// previouslySeen returns the value already being built for typ in this run. It +// uses the original type declaration so copied types find the same in-progress +// value and recursive definitions stop. +func (r *ExampleGenerator) previouslySeen(typ UserType) (*any, bool) { + s := r.store() + if s.seen == nil { + return nil, false + } + val, haveSeen := s.seen[typ.Origin()] + return val, haveSeen +} + +// haveSeen records the value currently being built for typ so a recursive use +// can return it before construction finishes. +func (r *ExampleGenerator) haveSeen(typ UserType, val *any) { + s := r.store() + if s.seen == nil { + s.seen = make(map[UserType]*any) + } + + s.seen[typ.Origin()] = val +} + +// store returns the first generator, which stores the RandomizerFactory and the +// map of values currently being built. It returns r when r has no parent. +func (r *ExampleGenerator) store() *ExampleGenerator { + if r.root != nil { + return r.root + } + return r +} + +// structural returns a generator for the sequence selected by the supplied +// ExampleIdentity. It shares this run's map of values currently being built. +func (r *ExampleGenerator) structural(identity ExampleIdentity) *ExampleGenerator { + if r.factory == nil { + return r + } + if r.exampleRandomizer == nil { + panic("example generator must be anchored before structural descent") + } + return r.At(identity) +} diff --git a/expr/random_factory_test.go b/expr/random_factory_test.go new file mode 100644 index 0000000000..212a3000a1 --- /dev/null +++ b/expr/random_factory_test.go @@ -0,0 +1,246 @@ +// This file verifies that immutable example configuration creates independent +// mutable value streams for each code generation run. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +type ( + // customRandomizerFactory exercises the public factory contract without + // depending on Goa's built-in factory implementations. + customRandomizerFactory struct { + seed string + } + + recordingRandomizerFactory struct { + identities *[]expr.ExampleIdentity + } +) + +// NewRandomizer creates an independent seeded stream for identity. +func (f customRandomizerFactory) NewRandomizer(identity expr.ExampleIdentity) expr.Randomizer { + if identity.Seed() == "" { + panic("custom randomizer received an empty identity") + } + return expr.NewFakerRandomizerFactory(f.seed).NewRandomizer(identity) +} + +// NewRandomizer records the exact owner selected by example traversal and +// delegates value generation to Goa's deterministic factory. +func (f recordingRandomizerFactory) NewRandomizer(identity expr.ExampleIdentity) expr.Randomizer { + *f.identities = append(*f.identities, identity) + return expr.NewDeterministicRandomizerFactory().NewRandomizer(identity) +} + +func TestRandomizerFactoriesCreateIndependentStreams(t *testing.T) { + cases := []struct { + Name string + Factory expr.RandomizerFactory + }{ + {"faker", expr.NewFakerRandomizerFactory("seed")}, + {"deterministic", expr.NewDeterministicRandomizerFactory()}, + {"custom", customRandomizerFactory{seed: "seed"}}, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + identity := expr.MethodPayloadExampleIdentity(exampleMethod("service", "method")) + first := expr.NewExampleGenerator(c.Factory).At(identity) + second := expr.NewExampleGenerator(c.Factory).At(identity) + + require.NotSame(t, first, second) + require.Equal(t, first.String(), second.String()) + require.Equal(t, first.Int(), second.Int()) + }) + } +} + +// TestReleasedStandaloneRandomizers checks the released concrete randomizer +// types, seed, and repeatable values. +func TestReleasedStandaloneRandomizers(t *testing.T) { + faker := expr.NewFakerRandomizer("seed") + concrete, ok := faker.(*expr.FakerRandomizer) + require.True(t, ok) + require.Equal(t, "seed", concrete.Seed) + require.Equal(t, expr.NewFakerRandomizer("seed").String(), faker.String()) + + deterministic := expr.NewDeterministicRandomizer() + _, ok = deterministic.(*expr.DeterministicRandomizer) + require.True(t, ok) + require.Equal(t, "abc123", deterministic.String()) +} + +func TestRandomizerFactoriesPreserveDerivedExampleStability(t *testing.T) { + factory := expr.NewFakerRandomizerFactory("seed") + identity := expr.MethodPayloadExampleIdentity(exampleMethod("service", "method")) + first := expr.NewExampleGenerator(factory).At(identity) + second := expr.NewExampleGenerator(factory).At(identity) + + require.Equal(t, first.Member("payload").String(), second.Member("payload").String()) + require.Equal(t, first.ArrayElement(0).Int(), second.ArrayElement(0).Int()) +} + +func TestExampleIdentitiesFrameComponents(t *testing.T) { + for _, delimiter := range []string{".", "/", ":"} { + t.Run(delimiter, func(t *testing.T) { + left := expr.MethodPayloadExampleIdentity(exampleMethod("a"+delimiter+"b", "c")) + right := expr.MethodPayloadExampleIdentity(exampleMethod("a", "b"+delimiter+"c")) + + require.NotEqual(t, left.Seed(), right.Seed()) + }) + } +} + +func TestExampleIdentitiesDistinguishSemanticAndStructuralKinds(t *testing.T) { + method := exampleMethod("service", "method") + payload := expr.MethodPayloadExampleIdentity(method) + result := expr.MethodResultExampleIdentity(method) + + require.NotEqual(t, payload.Seed(), result.Seed()) + require.NotEqual(t, payload.Member("0").Seed(), payload.ArrayElement(0).Seed()) + require.NotEqual(t, payload.Member("value").Seed(), payload.UnionMember("value").Seed()) + require.NotEqual(t, payload.MapKey(0).Seed(), payload.MapValue(0).Seed()) + errorIdentity := expr.MethodErrorExampleIdentity(method, &expr.ErrorExpr{Name: "failure"}) + require.NotEqual(t, result.Member("failure").Seed(), errorIdentity.Seed()) +} + +func TestHTTPResponseIdentitiesIgnoreTraversalOrderAndDistinguishErrors(t *testing.T) { + method := exampleMethod("service", "method") + endpoint := &expr.HTTPEndpointExpr{MethodExpr: method} + ok := &expr.HTTPResponseExpr{StatusCode: expr.StatusOK} + created := &expr.HTTPResponseExpr{StatusCode: expr.StatusCreated} + responses := []*expr.HTTPResponseExpr{ok, created} + before := map[int]string{ + ok.StatusCode: expr.ResponseBodyExampleIdentity(endpoint, responses[0]).Seed(), + created.StatusCode: expr.ResponseBodyExampleIdentity(endpoint, responses[1]).Seed(), + } + + responses[0], responses[1] = responses[1], responses[0] + require.Equal(t, before[created.StatusCode], expr.ResponseBodyExampleIdentity(endpoint, responses[0]).Seed()) + require.Equal(t, before[ok.StatusCode], expr.ResponseBodyExampleIdentity(endpoint, responses[1]).Seed()) + + firstError := &expr.HTTPErrorExpr{Name: "missing", Response: &expr.HTTPResponseExpr{StatusCode: expr.StatusNotFound}} + secondError := &expr.HTTPErrorExpr{Name: "gone", Response: &expr.HTTPResponseExpr{StatusCode: expr.StatusNotFound}} + require.NotEqual(t, + expr.ErrorResponseBodyExampleIdentity(endpoint, firstError).Seed(), + expr.ErrorResponseBodyExampleIdentity(endpoint, secondError).Seed(), + ) + require.NotEqual(t, + expr.ResponseBodyExampleIdentity(endpoint, &expr.HTTPResponseExpr{StatusCode: expr.StatusNotFound}).Seed(), + expr.ErrorResponseBodyExampleIdentity(endpoint, firstError).Seed(), + ) +} + +func TestHTTPBodyIdentitiesDistinguishHTTPAndJSONRPCMappings(t *testing.T) { + method := exampleMethod("service", "method") + httpEndpoint := &expr.HTTPEndpointExpr{MethodExpr: method} + jsonRPCEndpoint := &expr.HTTPEndpointExpr{ + MethodExpr: method, + Meta: expr.MetaExpr{"jsonrpc": {}}, + } + + require.NotEqual(t, + expr.RequestBodyExampleIdentity(httpEndpoint).Seed(), + expr.RequestBodyExampleIdentity(jsonRPCEndpoint).Seed(), + ) +} + +func TestGRPCMessageIdentitiesDistinguishExactMethodsAndRoles(t *testing.T) { + dashed := exampleMethod("service", "foo-bar") + underscore := exampleMethod("service", "foo_bar") + errorExpr := &expr.ErrorExpr{Name: "failure"} + + require.NotEqual(t, + expr.GRPCRequestMessageExampleIdentity(dashed).Seed(), + expr.GRPCRequestMessageExampleIdentity(underscore).Seed(), + ) + require.NotEqual(t, + expr.GRPCRequestMessageExampleIdentity(dashed).Seed(), + expr.GRPCResponseMessageExampleIdentity(dashed).Seed(), + ) + require.NotEqual(t, + expr.GRPCStreamingRequestMessageExampleIdentity(dashed).Seed(), + expr.GRPCStreamingResponseMessageExampleIdentity(dashed).Seed(), + ) + require.NotEqual(t, + expr.GRPCResponseMessageExampleIdentity(dashed).Seed(), + expr.GRPCErrorMessageExampleIdentity(dashed, errorExpr).Seed(), + ) +} + +func TestInlineMethodErrorsRetainMethodErrorIdentity(t *testing.T) { + root := expr.RunDSL(t, func() { + var authored = dsl.Type("AuthoredError", func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Error("inline", dsl.String) + dsl.Error("authored", authored) + }) + }) + }) + method := root.Service("Values").Method("Read") + cases := []struct { + name string + error *expr.ErrorExpr + expected expr.ExampleIdentity + }{ + { + name: "inline", + error: method.Error("inline"), + expected: expr.MethodErrorExampleIdentity(method, method.Error("inline")), + }, + { + name: "authored", + error: method.Error("authored"), + expected: expr.UserTypeExampleIdentity(root.UserType("AuthoredError")), + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + var identities []expr.ExampleIdentity + generator := expr.NewExampleGenerator(recordingRandomizerFactory{identities: &identities}) + test.error.AttributeExpr.Example(generator.At(expr.MethodPayloadExampleIdentity(method))) + + require.NotEmpty(t, identities) + require.Contains(t, identities, test.expected) + }) + } +} + +func TestConfiguredExampleGeneratorRequiresIdentity(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + + require.Panics(t, func() { + attribute.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("seed"))) + }) +} + +func TestZeroExampleIdentityCannotCreateStructuralIdentity(t *testing.T) { + var identity expr.ExampleIdentity + + require.Panics(t, func() { + identity.Member("field") + }) +} + +func TestDisabledExampleGeneratorSuppressesAuthoredExamples(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: expr.String, + UserExamples: []*expr.ExampleExpr{{Value: "authored"}}, + } + + require.Nil(t, attribute.Example(&expr.ExampleGenerator{})) +} + +func exampleMethod(service, method string) *expr.MethodExpr { + svc := &expr.ServiceExpr{Name: service} + return &expr.MethodExpr{Name: method, Service: svc} +} diff --git a/expr/result_type.go b/expr/result_type.go index 5876bd5a58..70f323aca6 100644 --- a/expr/result_type.go +++ b/expr/result_type.go @@ -1,3 +1,5 @@ +// This file defines result types and views and records the original +// declaration used when a result type is copied. package expr import ( @@ -29,6 +31,9 @@ type ( ContentType string // Views list the supported views indexed by name. Views []*ViewExpr + // origin is the earliest result type declaration copied to create this + // result type. + origin UserType } // ViewExpr defines which fields to render when building a response. The view @@ -57,6 +62,7 @@ var ( Type: errorResultType, Description: "Error response result type", Validation: &ValidationExpr{Required: []string{"name", "id", "message", "temporary", "timeout", "fault"}}, + finalized: true, }, TypeName: "error", }, @@ -114,6 +120,13 @@ func NewResultTypeExpr(name, identifier string, fn func()) *ResultTypeExpr { } } +// IsErrorResult reports whether dataType is Goa's built-in service error type +// or a generator copy made from it. +func IsErrorResult(dataType DataType) bool { + userType, ok := dataType.(UserType) + return ok && userType.Origin() == ErrorResult +} + // CanonicalIdentifier returns the result type identifier sans suffix // which is what the DSL uses to store and lookup result types. func CanonicalIdentifier(identifier string) string { @@ -137,7 +150,18 @@ func (rt *ResultTypeExpr) Dup(att *AttributeExpr) UserType { UserTypeExpr: rt.UserTypeExpr.Dup(att).(*UserTypeExpr), Identifier: rt.Identifier, Views: rt.Views, + origin: rt.Origin(), + } +} + +// Origin returns the earliest result type declaration from which rt was +// copied. Result types override their embedded user-type origin so later copies +// still point to the original result declaration. +func (rt *ResultTypeExpr) Origin() UserType { + if rt.origin != nil { + return rt.origin } + return rt } // ID returns the identifier of the result type. @@ -148,6 +172,13 @@ func (rt *ResultTypeExpr) ID() string { // Name returns the result type name. func (rt *ResultTypeExpr) Name() string { return rt.TypeName } +// Rename changes the result type name and starts a new generated declaration +// origin at rt. +func (rt *ResultTypeExpr) Rename(name string) { + rt.UserTypeExpr.Rename(name) + rt.origin = nil +} + // View returns the view with the given name. func (rt *ResultTypeExpr) View(name string) *ViewExpr { for _, v := range rt.Views { @@ -291,14 +322,8 @@ func projectSingle(rt *ResultTypeExpr, view string, seen map[string]UserType) (* } id := rt.projectIdentifier(view) - ut := &UserTypeExpr{ - AttributeExpr: &AttributeExpr{ - Description: desc, - Validation: val, - }, - TypeName: typeName, - UID: id, - } + attribute := &AttributeExpr{Description: desc, Validation: val} + ut := projectedUserType(rt, typeName, id, attribute) ut.Type = Dup(v.Type) ut.UserExamples = v.UserExamples projected := &ResultTypeExpr{ @@ -340,17 +365,14 @@ func projectCollection(rt *ResultTypeExpr, view string, seen map[string]UserType // Build the projected collection with the results id := rt.projectIdentifier(view) + attribute := &AttributeExpr{ + Description: rt.TypeName + " is the result type for an array of " + e.TypeName + " (" + view + " view)", + Type: &Array{ElemType: &AttributeExpr{Type: pe}}, + UserExamples: rt.UserExamples, + } proj := &ResultTypeExpr{ - Identifier: id, - UserTypeExpr: &UserTypeExpr{ - AttributeExpr: &AttributeExpr{ - Description: rt.TypeName + " is the result type for an array of " + e.TypeName + " (" + view + " view)", - Type: &Array{ElemType: &AttributeExpr{Type: pe}}, - UserExamples: rt.UserExamples, - }, - TypeName: pe.TypeName + "Collection", - UID: id, - }, + Identifier: id, + UserTypeExpr: projectedUserType(rt, pe.TypeName+"Collection", id, attribute), Views: []*ViewExpr{{ AttributeExpr: DupAtt(pe.View(DefaultView).AttributeExpr), Name: DefaultView, @@ -367,6 +389,16 @@ func projectCollection(rt *ResultTypeExpr, view string, seen map[string]UserType return proj, nil } +// projectedUserType makes a synthesized result type use the same repeatable +// example sequence as source. A view-specific type authored in the design keeps +// its media-type-derived UID instead. +func projectedUserType(source UserType, name, uid string, attribute *AttributeExpr) *UserTypeExpr { + if identity, ok := GeneratedUserTypeExampleIdentity(source); ok { + return NewGeneratedUserType(name, attribute, identity) + } + return &UserTypeExpr{AttributeExpr: attribute, TypeName: name, UID: uid} +} + // projectRecursive computes the projected attribute for the field described // by at within a result type being projected with view. vat is the matching // view attribute. It always returns a fresh attribute: projected types are diff --git a/expr/root.go b/expr/root.go index 5ba20b8580..913107736c 100644 --- a/expr/root.go +++ b/expr/root.go @@ -1,8 +1,12 @@ +// This file defines the evaluated design root and validates relationships +// between its API, services, generated types, and explicitly relocated user +// types before code generation begins. package expr import ( "fmt" "maps" + "reflect" "slices" "sort" @@ -87,6 +91,9 @@ func (r *RootExpr) WalkSets(walk eval.SetWalker) { walk(rtypes) // Services + for _, service := range r.Services { + service.design = r + } walk(eval.ToExpressionSet(r.Services)) // Methods (must be done after services) @@ -214,7 +221,45 @@ func (r *RootExpr) Validate() error { } verr.Merge(r.validateRelocatedUserTypes()) + verr.Merge(validateTypeMappings("conversion", r.Conversions)) + verr.Merge(validateTypeMappings("creation", r.Creations)) + + return &verr +} +// validateTypeMappings rejects repeated declarations that would generate the +// same method on one user type. A reflected type includes its package path, so +// equally named external types from different packages remain distinct. +func validateTypeMappings(direction string, mappings []*TypeMap) *eval.ValidationErrors { + type mappingIdentity struct { + user UserType + external reflect.Type + } + var verr eval.ValidationErrors + seen := make(map[mappingIdentity]struct{}, len(mappings)) + for _, mapping := range mappings { + identity := mappingIdentity{ + user: mapping.User.Origin(), + external: reflect.TypeOf(mapping.External), + } + if _, ok := seen[identity]; ok { + if direction == "conversion" { + verr.Add( + mapping.User, + "conversion from user type %q to external type %q defined twice", + mapping.User.Name(), identity.external, + ) + } else { + verr.Add( + mapping.User, + "creation from external type %q to user type %q defined twice", + identity.external, mapping.User.Name(), + ) + } + continue + } + seen[identity] = struct{}{} + } return &verr } @@ -229,21 +274,21 @@ func (r *RootExpr) Validate() error { // types. func (r *RootExpr) validateRelocatedUserTypes() *eval.ValidationErrors { var verr eval.ValidationErrors - declared := make(map[string]struct{}, len(r.Types)) + declared := make(map[UserType]struct{}, len(r.Types)) for _, ut := range r.Types { - declared[ut.ID()] = struct{}{} + declared[ut.Origin()] = struct{}{} } for _, ut := range r.Types { pkgPath, ok := ut.Attribute().Meta.Last("struct:pkg:path") if !ok || pkgPath == "" { continue } - seen := make(map[string]struct{}) + seen := make(map[UserType]struct{}) r.walkUserTypeDependencies(ut, seen, "", func(dep UserType, path string) { - if dep.ID() == ut.ID() { + if dep.Origin() == ut.Origin() { return } - if _, ok := declared[dep.ID()]; !ok { + if _, ok := declared[dep.Origin()]; !ok { // Generated/derived user types (e.g. union branch wrappers) are // materialized alongside their owning types and do not require an // explicit struct:pkg:path. @@ -275,7 +320,7 @@ func (r *RootExpr) validateRelocatedUserTypes() *eval.ValidationErrors { // walkUserTypeDependencies traverses the attribute graph reachable from root and // invokes visit for each encountered user type. -func (r *RootExpr) walkUserTypeDependencies(root UserType, seen map[string]struct{}, path string, visit func(UserType, string)) { +func (r *RootExpr) walkUserTypeDependencies(root UserType, seen map[UserType]struct{}, path string, visit func(UserType, string)) { if root == nil || root.Attribute() == nil { return } @@ -287,16 +332,17 @@ func (r *RootExpr) walkUserTypeDependencies(root UserType, seen map[string]struc // // The path argument records the traversal path through objects, arrays, maps, // and unions and is intended for diagnostics. -func (r *RootExpr) walkAttributeUserTypes(att *AttributeExpr, seen map[string]struct{}, path string, visit func(UserType, string)) { +func (r *RootExpr) walkAttributeUserTypes(att *AttributeExpr, seen map[UserType]struct{}, path string, visit func(UserType, string)) { if att == nil || att.Type == Empty { return } switch t := att.Type.(type) { case UserType: - if _, ok := seen[t.ID()]; ok { + origin := t.Origin() + if _, ok := seen[origin]; ok { return } - seen[t.ID()] = struct{}{} + seen[origin] = struct{}{} visit(t, path) r.walkAttributeUserTypes(t.Attribute(), seen, path, visit) case *Object: diff --git a/expr/root_test.go b/expr/root_test.go index a177ad2195..d84e52c908 100644 --- a/expr/root_test.go +++ b/expr/root_test.go @@ -1,13 +1,70 @@ +// This file verifies root validation, including exact-origin dependency +// traversal for explicitly relocated user types. package expr import ( "errors" "fmt" + "strings" "testing" "goa.design/goa/v3/eval" ) +type rootExternalType struct { + Value string +} + +func TestRelocatedDependenciesUseDeclarationOrigin(t *testing.T) { + dependency := &UserTypeExpr{ + TypeName: "Dependency", + UID: "shared-semantic-id", + AttributeExpr: &AttributeExpr{Type: String}, + } + relocated := &UserTypeExpr{ + TypeName: "Relocated", + UID: "shared-semantic-id", + AttributeExpr: &AttributeExpr{ + Meta: MetaExpr{"struct:pkg:path": {"types"}}, + Type: &Object{&NamedAttributeExpr{ + Name: "dependency", + Attribute: &AttributeExpr{Type: dependency}, + }}, + }, + } + root := &RootExpr{Types: []UserType{relocated, dependency}} + + errors := root.validateRelocatedUserTypes() + if len(errors.Errors) != 1 { + t.Fatalf("expected one relocated dependency error, got %d", len(errors.Errors)) + } + if message := errors.Errors[0].Error(); !strings.Contains(message, "Dependency") { + t.Errorf("expected dependency name in error, got %q", message) + } +} + +func TestRelocatedDependencyWalkStopsAtExactOriginCopy(t *testing.T) { + relocated := &UserTypeExpr{ + TypeName: "Relocated", + UID: "relocated", + AttributeExpr: &AttributeExpr{ + Meta: MetaExpr{"struct:pkg:path": {"types"}}, + Type: String, + }, + } + copy := relocated.Dup(DupAtt(relocated.Attribute())) + relocated.AttributeExpr.Type = &Object{&NamedAttributeExpr{ + Name: "self", + Attribute: &AttributeExpr{Type: copy}, + }} + root := &RootExpr{Types: []UserType{relocated}} + + errors := root.validateRelocatedUserTypes() + if len(errors.Errors) != 0 { + t.Errorf("expected exact origin copy to be treated as recursion, got %v", errors) + } +} + func TestRootExprValidate(t *testing.T) { cases := map[string]struct { api *APIExpr @@ -46,6 +103,49 @@ func TestRootExprValidate(t *testing.T) { } } +// TestRootExprValidateRejectsDuplicateTypeMappings catches two identical +// conversion or creation declarations that would emit the same receiver method. +func TestRootExprValidateRejectsDuplicateTypeMappings(t *testing.T) { + user := &UserTypeExpr{ + TypeName: "Value", + UID: "value", + AttributeExpr: &AttributeExpr{Type: String}, + } + for _, test := range []struct { + name string + conversions []*TypeMap + creations []*TypeMap + }{ + { + name: "conversion", + conversions: []*TypeMap{ + {User: user, External: rootExternalType{}}, + {User: user, External: rootExternalType{}}, + }, + }, + { + name: "creation", + creations: []*TypeMap{ + {User: user, External: rootExternalType{}}, + {User: user, External: rootExternalType{}}, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + root := &RootExpr{ + API: &APIExpr{Name: "test"}, + Types: []UserType{user}, + Conversions: test.conversions, + Creations: test.creations, + } + err := root.Validate() + if err == nil || !strings.Contains(err.Error(), test.name+" from") || !strings.Contains(err.Error(), "defined twice") { + t.Fatalf("expected precise duplicate %s error, got %v", test.name, err) + } + }) + } +} + func TestMetaExpr_Last(t *testing.T) { tt := map[string]struct { meta MetaExpr diff --git a/expr/security.go b/expr/security.go index 6ae1c45c95..7ef6d55e4e 100644 --- a/expr/security.go +++ b/expr/security.go @@ -83,6 +83,9 @@ type ( Flows []*FlowExpr // Meta is a list of key/value pairs Meta MetaExpr + // authored points to the security scheme copied for a transport. It is + // nil while this value is the scheme declared by the design. + authored *SchemeExpr } // FlowExpr describes a specific OAuth2 flow. @@ -141,10 +144,20 @@ func DupScheme(sch *SchemeExpr) *SchemeExpr { Scopes: sch.Scopes, Flows: sch.Flows, Meta: sch.Meta, + authored: sch.AuthoredScheme(), } return &dup } +// AuthoredScheme returns the security scheme declared by the design. It +// returns s when s has not been copied for a transport. +func (s *SchemeExpr) AuthoredScheme() *SchemeExpr { + if s.authored != nil { + return s.authored + } + return s +} + // HasNoSecurity returns true if the security requirements explicitly disable // security. func HasNoSecurity(reqs []*SecurityExpr) bool { diff --git a/expr/service.go b/expr/service.go index 43887d95d6..75dff39454 100644 --- a/expr/service.go +++ b/expr/service.go @@ -1,8 +1,11 @@ +// This file defines services and their errors. It also distinguishes errors +// named in the design from errors created for one method. package expr import ( "errors" "fmt" + "strings" "goa.design/goa/v3/eval" ) @@ -34,6 +37,8 @@ type ( // Meta is a set of key/value pairs with semantic that is // specific to each generator. Meta MetaExpr + // design points to the root containing this service. + design *RootExpr } // ErrorExpr defines an error response. It consists of a named @@ -72,7 +77,7 @@ func (s *ServiceExpr) Error(name string) *ErrorExpr { return erro } } - return Root.Error(name) + return s.design.Error(name) } // Hash returns a unique hash value for s. @@ -91,9 +96,59 @@ func (s *ServiceExpr) Validate() error { } } } + verr.Merge(s.validateInlineMethodErrors()) return verr } +// validateInlineMethodErrors rejects two inline errors that request one public +// Go error name but define different values. +func (s *ServiceExpr) validateInlineMethodErrors() *eval.ValidationErrors { + verr := new(eval.ValidationErrors) + seen := make(map[string]*ErrorExpr) + for _, serviceError := range s.Errors { + if standardErrorUsesGeneratedConstructor(serviceError) { + seen[serviceError.Name] = serviceError + } + } + for _, method := range s.Methods { + for _, methodError := range method.Errors { + if !standardErrorUsesGeneratedConstructor(methodError) { + continue + } + if previous := seen[methodError.Name]; previous != nil { + if !equivalentErrorAttributes(previous.AttributeExpr, methodError.AttributeExpr) { + if settings := differingErrorQualifierSettings(previous.AttributeExpr, methodError.AttributeExpr); len(settings) > 0 { + verr.Add( + methodError, + "error %q cannot use one generated constructor because its %s setting differs in service %q", + methodError.Name, + strings.Join(settings, ", "), + s.Name, + ) + } else { + verr.Add( + methodError, + "inline error %q must define the same value contract in every method of service %q", + methodError.Name, + s.Name, + ) + } + } + continue + } + seen[methodError.Name] = methodError + } + } + return verr +} + +// standardErrorUsesGeneratedConstructor reports whether Goa generates the +// shared Make function whose behavior repeated declarations could change. +func standardErrorUsesGeneratedConstructor(errorExpression *ErrorExpr) bool { + userType, authored := errorExpression.Type.(UserType) + return !authored || IsErrorResult(userType) +} + // Finalize finalizes all the service methods and errors. func (s *ServiceExpr) Finalize() { for _, e := range s.Errors { @@ -134,7 +189,7 @@ func (e *ErrorExpr) Finalize() { att := e.AttributeExpr switch dt := att.Type.(type) { case UserType: - if dt != ErrorResult { + if !IsErrorResult(dt) { // If this type contains an attribute with "struct:error:name" meta // then no need to do anything. if IsObject(dt) { @@ -158,3 +213,37 @@ func (e *ErrorExpr) Finalize() { e.AttributeExpr = &AttributeExpr{Type: ut} } } + +// finalizeMethodType wraps an inline method error and assigns the repeatable +// example key used by service and transport generators for that method error. +func (e *ErrorExpr) finalizeMethodType(method *MethodExpr) { + e.AttributeExpr = &AttributeExpr{Type: newGeneratedUserType( + e.Name, + e.AttributeExpr, + MethodErrorExampleIdentity(method, e), + previousInlineMethodErrorOrigin(method, e.Name), + )} +} + +// previousInlineMethodErrorOrigin returns the declaration already created for +// the same inline error by an earlier method in this service. +func previousInlineMethodErrorOrigin(method *MethodExpr, name string) UserType { + for _, previousMethod := range method.Service.Methods { + if previousMethod == method { + return nil + } + for _, previousError := range previousMethod.Errors { + if previousError.Name != name { + continue + } + userType, ok := previousError.Type.(UserType) + if !ok { + continue + } + if _, generated := GeneratedUserTypeExampleIdentity(userType); generated { + return userType.Origin() + } + } + } + return nil +} diff --git a/expr/service_test.go b/expr/service_test.go index 6cf39903aa..0eee205bca 100644 --- a/expr/service_test.go +++ b/expr/service_test.go @@ -4,7 +4,10 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/expr/testdata" ) @@ -45,6 +48,115 @@ func TestServiceExprMethod(t *testing.T) { } } +func TestEquivalentInlineMethodErrorsShareOrigin(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("secured", func() { + for _, method := range []string{"read", "write"} { + dsl.Method(method, func() { + dsl.Error("invalid_scopes", dsl.String) + }) + } + }) + }) + service := root.Service("secured") + first := service.Method("read").Error("invalid_scopes").Type.(expr.UserType) + second := service.Method("write").Error("invalid_scopes").Type.(expr.UserType) + + require.NotSame(t, first, second) + require.Same(t, first.Origin(), second.Origin()) + firstIdentity, ok := expr.GeneratedUserTypeExampleIdentity(first) + require.True(t, ok) + secondIdentity, ok := expr.GeneratedUserTypeExampleIdentity(second) + require.True(t, ok) + require.NotEqual(t, firstIdentity, secondIdentity) +} + +func TestIncompatibleInlineMethodErrorsAreRejected(t *testing.T) { + expr.ResetDSL(t) + design := func() { + dsl.Service("secured", func() { + dsl.Method("read", func() { + dsl.Error("invalid_scopes", dsl.String) + }) + dsl.Method("write", func() { + dsl.Error("invalid_scopes", dsl.Int) + }) + }) + } + require.True(t, eval.Execute(design, nil)) + err := eval.RunDSL() + require.ErrorContains(t, err, `inline error "invalid_scopes" must define the same value contract in every method of service "secured"`) +} + +func TestRepeatedStandardErrorsMustUseSameQualifiers(t *testing.T) { + qualifiers := []struct { + name string + apply func() + }{ + {name: "temporary", apply: dsl.Temporary}, + {name: "timeout", apply: func() { dsl.Timeout() }}, + {name: "fault", apply: dsl.Fault}, + } + for _, qualifier := range qualifiers { + t.Run("service and method "+qualifier.name, func(t *testing.T) { + expr.ResetDSL(t) + design := func() { + dsl.Service("jobs", func() { + dsl.Error("busy", qualifier.apply) + dsl.Method("run", func() { + dsl.Error("busy") + }) + }) + } + require.True(t, eval.Execute(design, nil)) + err := eval.RunDSL() + require.ErrorContains(t, err, qualifier.name+" setting differs") + }) + + t.Run("two methods "+qualifier.name, func(t *testing.T) { + expr.ResetDSL(t) + design := func() { + dsl.Service("jobs", func() { + dsl.Method("start", func() { + dsl.Error("busy", qualifier.apply) + }) + dsl.Method("resume", func() { + dsl.Error("busy") + }) + }) + } + require.True(t, eval.Execute(design, nil)) + err := eval.RunDSL() + require.ErrorContains(t, err, qualifier.name+" setting differs") + }) + + t.Run("matching "+qualifier.name, func(t *testing.T) { + expr.RunDSL(t, func() { + dsl.Service("jobs", func() { + dsl.Error("busy", qualifier.apply) + dsl.Method("run", func() { + dsl.Error("busy", qualifier.apply) + }) + }) + }) + }) + } +} + +func TestRepeatedAuthoredErrorTypesDoNotShareGeneratedConstructors(t *testing.T) { + custom := dsl.Type("CustomError", func() { + dsl.Attribute("message", dsl.String) + }) + expr.RunDSL(t, func() { + dsl.Service("jobs", func() { + dsl.Error("busy", custom, dsl.Temporary) + dsl.Method("run", func() { + dsl.Error("busy", custom) + }) + }) + }) +} + func TestServiceExprError(t *testing.T) { var ( errorFoo = &expr.ErrorExpr{ @@ -72,14 +184,17 @@ func TestServiceExprError(t *testing.T) { }, } - expr.Root.Errors = []*expr.ErrorExpr{ - errorBar, - } s := expr.ServiceExpr{ Errors: []*expr.ErrorExpr{ errorFoo, }, } + design := &expr.RootExpr{ + API: expr.NewAPIExpr("test", func() {}), + Errors: []*expr.ErrorExpr{errorBar}, + Services: []*expr.ServiceExpr{&s}, + } + design.WalkSets(func(eval.ExpressionSet) {}) for k, tc := range cases { t.Run(k, func(t *testing.T) { if actual := s.Error(tc.name); actual != tc.expected { diff --git a/expr/streaming_response_mapping_test.go b/expr/streaming_response_mapping_test.go new file mode 100644 index 0000000000..9aaf07e45d --- /dev/null +++ b/expr/streaming_response_mapping_test.go @@ -0,0 +1,101 @@ +// This file checks that a method which returns many results cannot put result +// fields in HTTP headers or cookies. One connection has only one HTTP response, +// so it cannot carry different values for each result. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestStreamingSuccessResponseRejectsHeadersAndCookies checks HTTP methods over +// SSE and WebSocket connections and JSON-RPC methods over SSE. +func TestStreamingSuccessResponseRejectsHeadersAndCookies(t *testing.T) { + transports := []struct { + name string + dsl func(func()) + }{ + {name: "HTTP server-sent events", dsl: httpStreamingResponseMappingDSL(true)}, + {name: "HTTP WebSocket", dsl: httpStreamingResponseMappingDSL(false)}, + {name: "JSON-RPC server-sent events", dsl: jsonRPCStreamingResponseMappingDSL(true)}, + } + mappings := []struct { + name string + apply func() + error string + }{ + {name: "header", apply: func() { Header("metadata:X-Metadata") }, error: "streaming success response cannot map result attributes to HTTP headers"}, + {name: "cookie", apply: func() { Cookie("metadata:session") }, error: "streaming success response cannot map result attributes to HTTP cookies"}, + } + + for _, transport := range transports { + for _, mapping := range mappings { + t.Run(transport.name+" "+mapping.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + transport.dsl(mapping.apply) + }) + require.ErrorContains(t, err, mapping.error) + }) + } + } +} + +// httpStreamingResponseMappingDSL creates an HTTP method which returns many +// results and places one result field in its HTTP response. +func httpStreamingResponseMappingDSL(sse bool) func(func()) { + return func(mapping func()) { + Service("stream", func() { + Method("watch", func() { + StreamingResult(streamingMappedResult()) + HTTP(func() { + GET("/watch") + if sse { + ServerSentEvents(func() {}) + } + Response(func() { + mapping() + }) + }) + }) + }) + } +} + +// jsonRPCStreamingResponseMappingDSL creates a JSON-RPC method which returns +// many results and places one result field in its HTTP response. +func jsonRPCStreamingResponseMappingDSL(sse bool) func(func()) { + return func(mapping func()) { + Service("stream", func() { + JSONRPC(func() { + if sse { + POST("/watch") + } else { + GET("/watch") + } + }) + Method("watch", func() { + StreamingResult(streamingMappedResult()) + JSONRPC(func() { + if sse { + ServerSentEvents(func() {}) + } + Response(func() { + mapping() + }) + }) + }) + }) + } +} + +// streamingMappedResult defines the two fields used by these tests. +func streamingMappedResult() func() { + return func() { + Attribute("value", String) + Attribute("metadata", String) + } +} diff --git a/expr/testdata/endpoint_dsls.go b/expr/testdata/endpoint_dsls.go index ae54cab42c..162956e30f 100644 --- a/expr/testdata/endpoint_dsls.go +++ b/expr/testdata/endpoint_dsls.go @@ -1,3 +1,5 @@ +// This file defines reusable endpoint designs that exercise HTTP and gRPC +// preparation, validation, inheritance, streaming, and metadata behavior. package testdata import ( @@ -460,6 +462,20 @@ var EndpointPayloadMissingRequired = func() { }) } +var EndpointMultipartWithoutBody = func() { + Service("Service", func() { + Method("Method", func() { + Payload(func() { + Attribute("id", String) + }) + HTTP(func() { + POST("/{id}") + MultipartRequest() + }) + }) + }) +} + var StreamingEndpointRequestBody = func() { var PT = Type("Payload", func() { Attribute("foo", String) @@ -625,6 +641,35 @@ var GRPCEndpointWithAnyType = func() { }) } +var GRPCEndpointWithMixedResults = func() { + Service("Service", func() { + Method("Method", func() { + Result(String) + StreamingResult(Int) + GRPC(func() {}) + }) + }) +} + +var GRPCEndpointWithMatchingMixedResults = func() { + Service("Service", func() { + Method("Method", func() { + Result(String) + StreamingResult(String) + GRPC(func() {}) + }) + }) +} + +var GRPCEndpointWithStreamingResult = func() { + Service("Service", func() { + Method("Method", func() { + StreamingResult(String) + GRPC(func() {}) + }) + }) +} + var GRPCEndpointWithUntaggedFields = func() { var Req = Type("Req", func() { Attribute("req_not_field", String) @@ -714,6 +759,46 @@ var GRPCEndpointWithExtendedTypes = func() { }) } +var GRPCEndpointWithCompositeMetadata = func() { + objectValue := Type("MetadataObject", func() { + Attribute("name", String) + }) + Service("Service", func() { + Method("Method", func() { + Payload(func() { + Attribute("object", objectValue) + Attribute("mapping", MapOf(String, String)) + OneOf("choice", func() { + Attribute("text", String) + Attribute("count", Int) + }) + }) + Result(func() { + Attribute("object", objectValue) + Attribute("mapping", MapOf(String, String)) + OneOf("choice", func() { + Attribute("text", String) + Attribute("count", Int) + }) + }) + GRPC(func() { + Metadata(func() { + Attribute("object") + Attribute("mapping") + Attribute("choice") + }) + Response(func() { + Headers(func() { Attribute("object") }) + Trailers(func() { + Attribute("mapping") + Attribute("choice") + }) + }) + }) + }) + }) +} + var GRPCEndpointWithInheritErrorDSL = func() { API("API", func() { Error("not_found") diff --git a/expr/testdata/mixed_jsonrpc_transports.go b/expr/testdata/mixed_jsonrpc_transports.go deleted file mode 100644 index 3cf13ed25c..0000000000 --- a/expr/testdata/mixed_jsonrpc_transports.go +++ /dev/null @@ -1,151 +0,0 @@ -package testdata - -import ( - . "goa.design/goa/v3/dsl" -) - -// MixedJSONRPCTransportsAPI defines an API with mixed JSON-RPC transports. -var MixedJSONRPCTransportsAPI = func() { - API("MixedTransports", func() { - Title("Mixed JSON-RPC Transports API") - Description("API demonstrating mixed HTTP and SSE JSON-RPC transports") - }) - - Service("MixedService", func() { - Description("Service with both HTTP and SSE JSON-RPC methods") - - // Regular HTTP method - Method("GetUser", func() { - Payload(func() { - ID("id", String, "User ID") - Required("id") - }) - Result(func() { - ID("id", String, "User ID") - Field(1, "name", String) - Field(2, "email", String) - Required("id") - }) - HTTP(func() { - POST("/users/{id}") - }) - JSONRPC(func() { - }) - }) - - // SSE streaming method - Method("WatchUsers", func() { - Payload(func() { - ID("request_id", String, "Request ID") - Field(1, "filter", String, "Filter expression") - Required("request_id") - }) - StreamingResult(func() { - Field(1, "user_id", String) - Field(2, "event", String) - Field(3, "timestamp", String) - }) - HTTP(func() { - POST("/users/watch") - ServerSentEvents() // Enable SSE for this method - }) - JSONRPC(func() { - }) - }) - - // Another regular HTTP method - Method("CreateUser", func() { - Payload(func() { - Field(1, "name", String) - Field(2, "email", String) - Required("name", "email") - }) - Result(func() { - Field(1, "id", String, "Created user ID") - }) - HTTP(func() { - POST("/users") - }) - JSONRPC(func() { - // Notification - no ID needed - }) - }) - - // Configure JSON-RPC endpoint - JSONRPC(func() { - Path("/api/rpc") - }) - }) -} - -// ValidWebSocketOnlyAPI shows WebSocket cannot mix with other transports. -var ValidWebSocketOnlyAPI = func() { - API("WebSocketOnly", func() { - Title("WebSocket Only API") - }) - - Service("WebSocketService", func() { - Description("Service with only WebSocket JSON-RPC methods") - - Method("Connect", func() { - Payload(func() { - ID("token", String, "Request token used as ID") - Required("token") - }) - StreamingPayload(func() { - Field(1, "message", String) - }) - StreamingResult(func() { - Field(1, "response", String) - }) - HTTP(func() { - GET("/ws") - }) - JSONRPC(func() { - }) - }) - - JSONRPC(func() { - Path("/ws") - }) - }) -} - -// InvalidMixedWebSocketAPI shows invalid mixing of WebSocket with other transports. -var InvalidMixedWebSocketAPI = func() { - API("InvalidMixed", func() { - Title("Invalid Mixed API") - }) - - Service("InvalidService", func() { - Description("Service incorrectly mixing WebSocket with HTTP") - - // WebSocket method - Method("Stream", func() { - StreamingPayload(String) - StreamingResult(String) - HTTP(func() { - GET("/stream") - }) - JSONRPC(func() { - // Streaming methods typically don't use ID - }) - }) - - // Regular HTTP method - THIS SHOULD CAUSE VALIDATION ERROR - Method("Get", func() { - Payload(String) - Result(String) - HTTP(func() { - POST("/get") - }) - JSONRPC(func() { - // This method mixes with WebSocket - should error - }) - }) - - JSONRPC(func() { - Path("/invalid") - }) - }) -} \ No newline at end of file diff --git a/expr/transport_error_contract_test.go b/expr/transport_error_contract_test.go new file mode 100644 index 0000000000..0dae5fff8e --- /dev/null +++ b/expr/transport_error_contract_test.go @@ -0,0 +1,342 @@ +// This file verifies that reusable transport error mappings never replace the +// service error contract selected by an endpoint method. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestHTTPInheritedErrorMappingUsesMethodError(t *testing.T) { + root := expr.RunDSL(t, equivalentHTTPErrorMappingDSL) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.HTTPErrors[0].ErrorExpr) +} + +func TestHTTPInheritedErrorMappingRejectsIncompatibleError(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + }{ + {"different type", incompatibleHTTPErrorMappingDSL}, + {"different validation", incompatibleHTTPErrorValidationDSL}, + {"different named type", incompatibleHTTPNamedErrorMappingDSL}, + {"different object", incompatibleHTTPObjectErrorMappingDSL}, + } { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, test.dsl) + require.ErrorContains(t, err, `HTTP error mapping "bad_request"`) + require.ErrorContains(t, err, `method "Show" of service "Errors"`) + require.ErrorContains(t, err, "must define the same error attribute") + }) + } +} + +func TestGRPCInheritedErrorMappingUsesMethodError(t *testing.T) { + root := expr.RunDSL(t, equivalentGRPCErrorMappingDSL) + endpoint := root.API.GRPC.Services[0].GRPCEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.GRPCErrors[0].ErrorExpr) +} + +func TestServiceErrorMappingsUseMethodError(t *testing.T) { + root := expr.RunDSL(t, equivalentServiceErrorMappingDSL) + httpEndpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + grpcEndpoint := root.API.GRPC.Services[0].GRPCEndpoints[0] + methodError := httpEndpoint.MethodExpr.Error("bad_request") + + require.Same(t, methodError, httpEndpoint.HTTPErrors[0].ErrorExpr) + require.Same(t, methodError, grpcEndpoint.GRPCErrors[0].ErrorExpr) +} + +func TestHTTPInheritedErrorMappingAcceptsEquivalentValidationOrder(t *testing.T) { + root := expr.RunDSL(t, equivalentHTTPErrorValidationOrderDSL) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.HTTPErrors[0].ErrorExpr) +} + +func TestHTTPInheritedErrorMappingUsesEffectiveInheritedContract(t *testing.T) { + root := expr.RunDSL(t, equivalentHTTPInheritedErrorMappingDSL) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.HTTPErrors[0].ErrorExpr) +} + +func TestGRPCInheritedErrorMappingUsesEffectiveInheritedContract(t *testing.T) { + root := expr.RunDSL(t, equivalentGRPCInheritedErrorMappingDSL) + endpoint := root.API.GRPC.Services[0].GRPCEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.GRPCErrors[0].ErrorExpr) +} + +func TestInheritedErrorMappingRejectsDifferentEffectiveBases(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + }{ + {"HTTP", incompatibleHTTPInheritedErrorMappingDSL}, + {"gRPC", incompatibleGRPCInheritedErrorMappingDSL}, + } { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, test.dsl) + require.ErrorContains(t, err, `error mapping "bad_request"`) + require.ErrorContains(t, err, "must define the same error attribute") + }) + } +} + +func TestGRPCInheritedErrorMappingRejectsIncompatibleError(t *testing.T) { + err := expr.RunInvalidDSL(t, incompatibleGRPCErrorMappingDSL) + require.ErrorContains(t, err, `gRPC error mapping "bad_request"`) + require.ErrorContains(t, err, `method "Show" of service "Errors"`) + require.ErrorContains(t, err, "must define the same error attribute") +} + +func TestInheritedErrorMappingReportsDifferentQualifiers(t *testing.T) { + qualifiers := []struct { + name string + apply func() + }{ + {name: "temporary", apply: Temporary}, + {name: "timeout", apply: func() { Timeout() }}, + {name: "fault", apply: Fault}, + } + for _, transport := range []string{"HTTP", "gRPC"} { + for _, qualifier := range qualifiers { + t.Run(transport+" "+qualifier.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, qualifierErrorMappingDSL(transport, qualifier.apply)) + require.ErrorContains(t, err, transport+` error mapping "busy"`) + require.ErrorContains(t, err, qualifier.name+" setting differs") + }) + } + } +} + +func qualifierErrorMappingDSL(transport string, qualifier func()) func() { + return func() { + API("errors", func() { + Error("busy", qualifier) + if transport == "HTTP" { + HTTP(func() { Response(StatusServiceUnavailable, "busy") }) + } else { + GRPC(func() { Response("busy", CodeUnavailable) }) + } + }) + Service("Jobs", func() { + Method("Run", func() { + Error("busy") + if transport == "HTTP" { + HTTP(func() { POST("/run") }) + } else { + GRPC(func() {}) + } + }) + }) + } +} + +var equivalentHTTPErrorMappingDSL = func() { + API("errors", func() { + Error("bad_request", String) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", String) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleHTTPErrorMappingDSL = func() { + API("errors", func() { + Error("bad_request", String) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", Int) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleHTTPErrorValidationDSL = func() { + API("errors", func() { + Error("bad_request", String, func() { MinLength(2) }) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", String, func() { MinLength(3) }) + HTTP(func() { GET("/") }) + }) + }) +} + +var equivalentHTTPErrorValidationOrderDSL = func() { + API("errors", func() { + Error("bad_request", String, func() { Enum("first", "second") }) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", String, func() { Enum("second", "first") }) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleHTTPNamedErrorMappingDSL = func() { + firstError := Type("FirstError", func() { Attribute("message", String) }) + secondError := Type("SecondError", func() { Attribute("message", String) }) + API("errors", func() { + Error("bad_request", firstError) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", secondError) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleHTTPObjectErrorMappingDSL = func() { + stringError := Type("StringError", func() { Attribute("header") }) + API("errors", func() { + Error("bad_request", stringError) + HTTP(func() { + Response("bad_request", StatusBadRequest, func() { Header("header") }) + }) + }) + Service("Errors", func() { + Error("bad_request") + Method("Show", func() { HTTP(func() { GET("/") }) }) + }) +} + +var equivalentGRPCErrorMappingDSL = func() { + API("errors", func() { + Error("bad_request", String) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", String) + GRPC(func() {}) + }) + }) +} + +var incompatibleGRPCErrorMappingDSL = func() { + API("errors", func() { + Error("bad_request", String) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", Int) + GRPC(func() {}) + }) + }) +} + +var equivalentServiceErrorMappingDSL = func() { + Service("Errors", func() { + Error("bad_request", String) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + Method("Show", func() { + Error("bad_request", String) + HTTP(func() { GET("/") }) + GRPC(func() {}) + }) + }) +} + +var equivalentHTTPInheritedErrorMappingDSL = func() { + base := Type("HTTPErrorBase", func() { + Attribute("message", String, func() { + Default("invalid") + Meta("struct:field:name", "Message") + }) + Required("message") + }) + API("errors", func() { + Error("bad_request", &expr.Object{}, func() { Extend(base) }) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", &expr.Object{}, func() { + Attribute("message", String, func() { + Default("invalid") + Meta("struct:field:name", "Message") + }) + Required("message") + }) + HTTP(func() { GET("/") }) + }) + }) +} + +var equivalentGRPCInheritedErrorMappingDSL = func() { + base := Type("GRPCErrorBase", func() { + Attribute("message", String, func() { Meta("rpc:tag", "1") }) + Required("message") + }) + API("errors", func() { + Error("bad_request", &expr.Object{}, func() { Extend(base) }) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", &expr.Object{}, func() { + Attribute("message", String, func() { Meta("rpc:tag", "1") }) + Required("message") + }) + GRPC(func() {}) + }) + }) +} + +var incompatibleHTTPInheritedErrorMappingDSL = func() { + stringBase := Type("HTTPStringErrorBase", func() { Attribute("value", String) }) + integerBase := Type("HTTPIntegerErrorBase", func() { Attribute("value", Int) }) + API("errors", func() { + Error("bad_request", &expr.Object{}, func() { Extend(stringBase) }) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", &expr.Object{}, func() { Extend(integerBase) }) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleGRPCInheritedErrorMappingDSL = func() { + stringBase := Type("GRPCStringErrorBase", func() { + Attribute("value", String, func() { Meta("rpc:tag", "1") }) + }) + integerBase := Type("GRPCIntegerErrorBase", func() { + Attribute("value", Int, func() { Meta("rpc:tag", "1") }) + }) + API("errors", func() { + Error("bad_request", &expr.Object{}, func() { Extend(stringBase) }) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", &expr.Object{}, func() { Extend(integerBase) }) + GRPC(func() {}) + }) + }) +} diff --git a/expr/types.go b/expr/types.go index ea789c1e6e..5ef84bd451 100644 --- a/expr/types.go +++ b/expr/types.go @@ -1,10 +1,11 @@ +// This file defines Goa's core design data types and the structural operations +// used by evaluation, validation, and code generation. package expr import ( "fmt" "reflect" "sort" - "strconv" "goa.design/goa/v3/eval" ) @@ -78,6 +79,9 @@ type ( CompositeExpr // ID returns the identifier for the user type. ID() string + // Origin returns the first user type declaration from which this value + // was copied. An authored value returns itself. + Origin() UserType // Rename changes the type name to the given value. Rename(string) // SetAttribute updates the underlying attribute. @@ -181,6 +185,7 @@ var Empty = &UserTypeExpr{ AttributeExpr: &AttributeExpr{ Description: "Empty represents empty values", Type: &Object{}, + finalized: true, }, } @@ -424,7 +429,7 @@ func (a *Array) Example(r *ExampleGenerator) any { for i := range count { // Derive the element value stream from the index so elements get // distinct yet design-stable values. - res[i] = a.ElemType.Example(r.Derived(strconv.Itoa(i))) + res[i] = a.ElemType.Example(r.ArrayElement(i)) if res[i] == nil { // Handle the case of recursive data structures res[i] = make(map[string]any) @@ -544,7 +549,7 @@ func (o *Object) Example(r *ExampleGenerator) any { for _, nat := range *o { // Derive the field value stream from the field name so a field // example only changes when the field itself changes. - if v := nat.Attribute.Example(r.Derived(nat.Name)); v != nil { + if v := nat.Attribute.Example(r.Member(nat.Name)); v != nil { res[nat.Name] = v } } @@ -590,8 +595,8 @@ func (m *Map) Example(r *ExampleGenerator) any { for i := range count { // Derive per-entry value streams from the entry index so entries // get distinct yet design-stable keys and values. - k := m.KeyType.Example(r.Derived("key" + strconv.Itoa(i))) - v := m.ElemType.Example(r.Derived("val" + strconv.Itoa(i))) + k := m.KeyType.Example(r.MapKey(i)) + v := m.ElemType.Example(r.MapValue(i)) if k != nil && v != nil { pair[k] = v } @@ -676,7 +681,7 @@ func (u *Union) Example(r *ExampleGenerator) any { nat := u.Values[r.Int()%len(u.Values)] return map[string]any{ u.GetTypeKey(): nat.Name, - u.GetValueKey(): nat.Attribute.Example(r.Derived(nat.Name)), + u.GetValueKey(): nat.Attribute.Example(r.UnionMember(nat.Name)), } } diff --git a/expr/types_test.go b/expr/types_test.go index 66e6358f60..db819003bf 100644 --- a/expr/types_test.go +++ b/expr/types_test.go @@ -1,3 +1,5 @@ +// This file verifies expression type conversion, compatibility, and example +// behavior, including the tagged representation produced for union values. package expr import "testing" @@ -919,7 +921,12 @@ func TestUnionExampleAndCompatibilityUseTaggedEnvelope(t *testing.T) { }, } - example := union.Example(NewRandom("test")) + example := union.Example(NewExampleGenerator(NewFakerRandomizerFactory("test")).At( + MethodPayloadExampleIdentity(&MethodExpr{ + Name: "union", + Service: &ServiceExpr{Name: "test"}, + }), + )) envelope, ok := example.(map[string]any) if !ok { t.Fatalf("expected tagged envelope, got %T", example) diff --git a/expr/user_type.go b/expr/user_type.go index 0d81160f7e..7f9504b327 100644 --- a/expr/user_type.go +++ b/expr/user_type.go @@ -1,22 +1,52 @@ +// This file defines user types and records the original declaration from +// which each copied type was created. package expr type ( - // UserTypeExpr describes user defined types. While a given design must - // ensure that the names are unique the code used to generate code can - // create multiple user types that share the same name (for example because - // generated in different packages). UID is always unique and makes it - // possible to avoid infinite recursions when traversing the data structures - // described by the attribute expression e.g. when computing example values. + // UserTypeExpr describes a type declared in a Goa design or created by a + // generator. One design cannot declare two types with the same name, but + // generators may create same-named types in different Go packages. UID keeps + // authored examples and result-type behavior tied to the declared type. + // Generated types use exampleIdentity instead. Origin points to the first + // UserTypeExpr from which a copied type was made. UserTypeExpr struct { // The embedded attribute expression. *AttributeExpr // Name of type TypeName string - // UID of type + // UID identifies an authored type across copies of its expression. UID string + // origin is the earliest declaration copied to create this type. + origin UserType + // exampleIdentity selects the repeatable example sequence for a type created + // by a transport generator. Authored types leave it empty and use ID. + exampleIdentity ExampleIdentity } ) +// NewGeneratedUserType creates a user type for generated transport data. +// The supplied ExampleIdentity selects the generated type's ID and repeatable +// example sequence. Copies of a request or response type therefore do not use +// examples belonging to the authored service type. +func NewGeneratedUserType(name string, attribute *AttributeExpr, identity ExampleIdentity) *UserTypeExpr { + return newGeneratedUserType(name, attribute, identity, nil) +} + +// newGeneratedUserType creates one generated wrapper. origin identifies a +// prior wrapper that represents the same generated Go declaration. +func newGeneratedUserType(name string, attribute *AttributeExpr, identity ExampleIdentity, origin UserType) *UserTypeExpr { + if identity.seed == "" { + panic("generated user type requires an example identity") + } + return &UserTypeExpr{ + AttributeExpr: attribute, + TypeName: name, + UID: "generated:" + identity.Seed(), + origin: origin, + exampleIdentity: identity, + } +} + // ID returns the unique identifier for the user type. func (u *UserTypeExpr) ID() string { if u.UID != "" { @@ -25,6 +55,14 @@ func (u *UserTypeExpr) ID() string { return u.Name() } +// Origin returns the earliest user type declaration from which u was copied. +func (u *UserTypeExpr) Origin() UserType { + if u.origin != nil { + return u.origin + } + return u +} + // Kind implements DataKind. func (*UserTypeExpr) Kind() Kind { return UserTypeKind } @@ -45,6 +83,7 @@ func (u *UserTypeExpr) Rename(n string) { u.AddMeta("name:original", u.TypeName) delete(u.Meta, "struct:type:name") u.TypeName = n + u.origin = nil } // IsCompatible returns true if u describes the (Go) type of val. @@ -69,9 +108,11 @@ func (u *UserTypeExpr) Dup(att *AttributeExpr) UserType { return u } return &UserTypeExpr{ - AttributeExpr: att, - TypeName: u.TypeName, - UID: u.UID, + AttributeExpr: att, + TypeName: u.TypeName, + UID: u.UID, + origin: u.Origin(), + exampleIdentity: u.exampleIdentity, } } @@ -90,16 +131,16 @@ func (u *UserTypeExpr) Example(r *ExampleGenerator) any { } func (u *UserTypeExpr) recExample(r *ExampleGenerator) *any { - if ex, ok := r.PreviouslySeen(u.ID()); ok { + if ex, ok := r.previouslySeen(u); ok { return ex } var ex any pex := &ex - r.HaveSeen(u.ID(), pex) + r.haveSeen(u, pex) // Anchor the value stream to the type identity so the example depends // only on the type definition, not on how many examples were computed // before it nor on which design path reached the type first. - actual := u.AttributeExpr.Example(r.Rebased(u.ID())) + actual := u.AttributeExpr.Example(r.At(UserTypeExampleIdentity(u))) *pex = actual return pex } diff --git a/expr/user_type_example_test.go b/expr/user_type_example_test.go index b9fc3b5425..282c8c1736 100644 --- a/expr/user_type_example_test.go +++ b/expr/user_type_example_test.go @@ -1,8 +1,12 @@ +// This file verifies authored and generated user types retain independent +// example ownership while recursive copies share one declaration origin. package expr_test import ( "testing" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/expr" ) @@ -34,7 +38,9 @@ func TestUserTypeWithUserExample(t *testing.T) { // Test with both randomizers to ensure user examples always take precedence t.Run("with faker randomizer", func(t *testing.T) { - exampleGen := expr.NewRandom("test") + exampleGen := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.UserTypeExampleIdentity(urlType), + ) example := attr.Example(exampleGen) if example != customURL { t.Errorf("Attribute with user example should return %q, got %q", customURL, example) @@ -42,10 +48,9 @@ func TestUserTypeWithUserExample(t *testing.T) { }) t.Run("with deterministic randomizer", func(t *testing.T) { - gen := expr.NewDeterministicRandomizer() - exampleGen := &expr.ExampleGenerator{ - Randomizer: gen, - } + exampleGen := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.UserTypeExampleIdentity(urlType), + ) example := attr.Example(exampleGen) if example != customURL { t.Errorf("Attribute with user example should return %q, got %q", customURL, example) @@ -81,10 +86,9 @@ func TestUserTypeFormatWithCustomExample(t *testing.T) { } // Test with deterministic randomizer (as reported in the issue) - gen := expr.NewDeterministicRandomizer() - exampleGen := &expr.ExampleGenerator{ - Randomizer: gen, - } + exampleGen := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.UserTypeExampleIdentity(urlType), + ) // The bug was that this would return "https://example.com/foo" instead of the custom example example := attr.Example(exampleGen) @@ -117,10 +121,9 @@ func TestIssue3716Regression(t *testing.T) { } // When generating an example for the object - gen := expr.NewDeterministicRandomizer() - exampleGen := &expr.ExampleGenerator{ - Randomizer: gen, - } + exampleGen := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.MethodPayloadExampleIdentity(exampleMethod("issue-3716", "object")), + ) example := obj.Example(exampleGen) objExample, ok := example.(map[string]any) @@ -162,10 +165,9 @@ func TestUserTypeWithOwnExample(t *testing.T) { } // Use deterministic randomizer - gen := expr.NewDeterministicRandomizer() - exampleGen := &expr.ExampleGenerator{ - Randomizer: gen, - } + exampleGen := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.UserTypeExampleIdentity(urlType), + ) // The UserType itself should return its custom example example := urlType.Example(exampleGen) @@ -174,3 +176,58 @@ func TestUserTypeWithOwnExample(t *testing.T) { t.Errorf("UserType with custom example should return %q, got %q", customExample, example) } } + +func TestUserTypeExamplesIgnoreStringIDCollisions(t *testing.T) { + method := exampleMethod("service", "method") + owner := expr.MethodPayloadExampleIdentity(method) + generated := expr.NewGeneratedUserType("Generated", &expr.AttributeExpr{Type: &expr.Object{ + {Name: "generated", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, owner) + generatedOwner, ok := expr.GeneratedUserTypeExampleIdentity(generated) + require.True(t, ok) + require.Equal(t, owner, generatedOwner) + authored := &expr.UserTypeExpr{ + TypeName: "Authored", + UID: generated.ID(), + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "authored", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + } + _, ok = expr.GeneratedUserTypeExampleIdentity(authored) + require.False(t, ok) + + cases := []struct { + name string + first *expr.UserTypeExpr + firstField string + second *expr.UserTypeExpr + secondField string + }{ + { + name: "authored then generated", + first: authored, + firstField: "authored", + second: generated, + secondField: "generated", + }, + { + name: "generated then authored", + first: generated, + firstField: "generated", + second: authored, + secondField: "authored", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) + first := test.first.Example(generator.At(expr.UserTypeExampleIdentity(test.first))).(map[string]any) + second := test.second.Example(generator.At(expr.UserTypeExampleIdentity(test.second))).(map[string]any) + + require.Contains(t, first, test.firstField) + require.NotContains(t, first, test.secondField) + require.Contains(t, second, test.secondField) + require.NotContains(t, second, test.firstField) + }) + } +} diff --git a/expr/user_type_test.go b/expr/user_type_test.go index a373a66d91..614fb8b55e 100644 --- a/expr/user_type_test.go +++ b/expr/user_type_test.go @@ -1,6 +1,45 @@ package expr -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUserTypeOrigin(t *testing.T) { + original := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "Value", + } + copy := original.Dup(DupAtt(original.Attribute())).(*UserTypeExpr) + copyOfCopy := copy.Dup(DupAtt(copy.Attribute())).(*UserTypeExpr) + require.Same(t, original, original.Origin()) + require.Same(t, original, copy.Origin()) + require.Same(t, original, copyOfCopy.Origin()) + + copy.Rename("RenamedValue") + renamedCopy := copy.Dup(DupAtt(copy.Attribute())).(*UserTypeExpr) + require.Same(t, copy, copy.Origin()) + require.Same(t, copy, renamedCopy.Origin()) + require.Same(t, original, copyOfCopy.Origin()) +} + +func TestIndependentUserTypesHaveDistinctOrigins(t *testing.T) { + first := &UserTypeExpr{AttributeExpr: &AttributeExpr{Type: String}, TypeName: "Value"} + second := &UserTypeExpr{AttributeExpr: &AttributeExpr{Type: String}, TypeName: "Value"} + require.NotSame(t, first.Origin(), second.Origin()) +} + +func TestResultTypeOriginPreservesDynamicType(t *testing.T) { + original := NewResultTypeExpr("Value", "application/vnd.value", nil) + copy := original.Dup(DupAtt(original.Attribute())).(*ResultTypeExpr) + require.Same(t, original, copy.Origin()) + + copy.Rename("RenamedValue") + renamedCopy := copy.Dup(DupAtt(copy.Attribute())).(*ResultTypeExpr) + require.Same(t, copy, copy.Origin()) + require.Same(t, copy, renamedCopy.Origin()) +} func TestUserTypeExprName(t *testing.T) { var ( diff --git a/go.mod b/go.mod index f8c1dd79fe..5b6c9ce6fb 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.12.1 + golang.org/x/mod v0.40.0 golang.org/x/text v0.41.0 golang.org/x/tools v0.49.0 google.golang.org/grpc v1.83.1 @@ -27,7 +28,6 @@ require ( github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/mod v0.40.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/grpc/codegen/client.go b/grpc/codegen/client.go index 9e63a6d1a6..903445e846 100644 --- a/grpc/codegen/client.go +++ b/grpc/codegen/client.go @@ -1,3 +1,5 @@ +// This file renders gRPC clients and codecs per service; each returned file +// owns the generated-type imports used by its conversions. package codegen import ( @@ -9,22 +11,21 @@ import ( "goa.design/goa/v3/expr" ) -// ClientFiles returns the client files that contain client methods to call the -// corresponding service methods along with the encoding and decoding logic. -func ClientFiles(genpkg string, services *ServicesData) []*codegen.File { - svcLen := len(services.Root.API.GRPC.Services) +// clientFiles returns the planned client methods and their encoders and decoders. +func clientFiles(services *ServicesData) []*codegen.File { + svcLen := len(services.servicePlans) fw := make([]*codegen.File, 2*svcLen) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = clientFile(genpkg, svc, services) + for i, servicePlan := range services.servicePlans { + fw[i] = addEndpointImports(clientFile(servicePlan.expression, services), services, servicePlan) } - for i, svc := range services.Root.API.GRPC.Services { - fw[i+svcLen] = clientEncodeDecode(genpkg, svc, services) + for i, servicePlan := range services.servicePlans { + fw[i+svcLen] = addEndpointImports(clientEncodeDecode(servicePlan.expression, services), services, servicePlan) } return fw } // clientFile returns the file implementing the gRPC client. -func clientFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func clientFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { var ( fpath string sections []*codegen.SectionTemplate @@ -33,6 +34,7 @@ func clientFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData ) { svcName := data.Service.PathName + outputPackage := path.Join(services.GenPkg(), "grpc", svcName, "client") fpath = filepath.Join(codegen.Gendir, "grpc", svcName, "client", "client.go") imports := []*codegen.ImportSpec{ {Path: "context"}, @@ -40,9 +42,11 @@ func clientFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), codegen.GoaNamedImport("grpc/pb", "goapb"), - {Path: path.Join(genpkg, svcName), Name: data.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: data.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: data.PkgName}, + services.ServiceImport(outputPackage, svc.Name()), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + } + if serviceHasViewedClientStream(data) { + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } sections = []*codegen.SectionTemplate{ codegen.Header(svc.Name()+" gRPC client", "client", imports), @@ -111,7 +115,7 @@ func clientFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData // clientEncodeDecode returns the file containing the gRPC client encoding and // decoding logic. -func clientEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func clientEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { var ( fpath string sections []*codegen.SectionTemplate @@ -120,9 +124,9 @@ func clientEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv ) { svcName := data.Service.PathName + outputPackage := path.Join(services.GenPkg(), "grpc", svcName, "client") fpath = filepath.Join(codegen.Gendir, "grpc", svcName, "client", "encode_decode.go") imports := []*codegen.ImportSpec{ - {Path: "fmt"}, {Path: "context"}, {Path: "strconv"}, {Path: "unicode/utf8"}, @@ -130,14 +134,20 @@ func clientEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv {Path: "google.golang.org/grpc/metadata"}, codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), - {Path: path.Join(genpkg, svcName), Name: data.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: data.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: data.PkgName}, + services.ServiceImport(outputPackage, svc.Name()), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + } + if requestMetadataNeedsFormat(data) { + imports = append(imports, &codegen.ImportSpec{Path: "fmt"}) + } + if serviceHasUnaryViewedResult(data) { + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } sections = []*codegen.SectionTemplate{codegen.Header(svc.Name()+" gRPC client encoders and decoders", "client", imports)} - fm := transTmplFuncs(svc, services) + fm := transTmplFuncs(data) + fm["hasInitArg"] = hasInitArg fm["metadataEncodeDecodeData"] = metadataEncodeDecodeData - fm["typeConversionData"] = typeConversionData + fm["typeStringExpressionData"] = typeStringExpressionData fm["isBearer"] = isBearer for _, e := range data.Endpoints { sections = append(sections, &codegen.SectionTemplate{ @@ -148,7 +158,7 @@ func clientEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv if e.PayloadRef != "" { sections = append(sections, &codegen.SectionTemplate{ Name: "request-encoder", - Source: grpcTemplates.Read(grpcRequestEncoderT, grpcConvertTypeToStringP, "string_conversion"), + Source: grpcTemplates.Read(grpcRequestEncoderT, grpcTypeToStringExpressionP), Data: e, FuncMap: fm, }) @@ -166,6 +176,18 @@ func clientEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv return &codegen.File{Path: fpath, SectionTemplates: sections} } +// hasInitArg reports whether a generated constructor consumes the named +// source variable. Templates use it to avoid declaring an unused variable for +// an empty protobuf message that only carries response metadata. +func hasInitArg(args []*InitArgData, name string) bool { + for _, arg := range args { + if arg.Name == name { + return true + } + } + return false +} + // isBearer returns true if the security scheme uses a Bearer scheme. func isBearer(schemes []*service.SchemeData) bool { for _, s := range schemes { diff --git a/grpc/codegen/client_cli.go b/grpc/codegen/client_cli.go index 22c1d0fe53..f998083ed6 100644 --- a/grpc/codegen/client_cli.go +++ b/grpc/codegen/client_cli.go @@ -1,55 +1,81 @@ +// This file renders gRPC command parsers and per-service payload builders, +// including relocated payload imports in the builder that references them. package codegen import ( + "fmt" "path" "path/filepath" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/cli" - "goa.design/goa/v3/expr" ) -// ClientCLIFiles returns the CLI files to generate a command-line client that -// makes gRPC requests. -func ClientCLIFiles(genpkg string, services *ServicesData) []*codegen.File { - if len(services.Root.API.GRPC.Services) == 0 { +type ( + // commandData adds the exact gRPC client constructor to the shared command + // data used by transport command-line generators. + commandData struct { + *cli.CommandData + // ClientInit is the client constructor called by ParseEndpoint. + ClientInit *codegen.NameDeclaration + } +) + +// clientCLIFiles returns the planned command-line client files. +func clientCLIFiles(services *ServicesData) []*codegen.File { + if len(services.servicePlans) == 0 { return nil } var ( - data = make([]*cli.CommandData, 0, len(services.Root.API.GRPC.Services)) - svcs = make([]*expr.GRPCServiceExpr, 0, len(services.Root.API.GRPC.Services)) + data = make([]*commandData, 0, len(services.servicePlans)) + svcs = make([]*grpcServicePlan, 0, len(services.servicePlans)) ) - for _, svc := range services.Root.API.GRPC.Services { + for _, servicePlan := range services.servicePlans { + svc := servicePlan.expression if len(svc.GRPCEndpoints) == 0 { continue } sd := services.Get(svc.Name()) - command := cli.BuildCommandData(sd.Service) - for _, e := range sd.Endpoints { - flags, buildFunction := buildFlags(e) + command := &commandData{ + CommandData: cli.BuildCommandData(sd.Service), + ClientInit: sd.ClientInitDeclaration, + } + for index, e := range sd.Endpoints { + flags, buildFunction := buildFlags(e, services.cliPlan.builders[svc.GRPCEndpoints[index]]) subcmd := cli.BuildSubcommandData(sd.Service, e.Method, buildFunction, flags) - command.Subcommands = append(command.Subcommands, subcmd) + command.CommandData.Subcommands = append(command.CommandData.Subcommands, subcmd) } command.Example = command.Subcommands[0].Example data = append(data, command) - svcs = append(svcs, svc) + svcs = append(svcs, servicePlan) } - files := make([]*codegen.File, 0, len(services.Root.API.Servers)+len(svcs)) - for _, svr := range services.Root.API.Servers { - files = append(files, endpointParser(genpkg, services, svr, data)) + files := make([]*codegen.File, 0, len(services.cliPlan.servers)+len(svcs)) + for _, serverPlan := range services.cliPlan.servers { + serverData := make([]*commandData, 0, len(serverPlan.expression.Services)) + for _, serviceName := range serverPlan.expression.Services { + for _, command := range data { + if command.ServiceName == serviceName { + serverData = append(serverData, command) + break + } + } + } + files = append(files, endpointParser(services, serverPlan, serverData)) } for i, svc := range svcs { - files = append(files, payloadBuilders(genpkg, svc, data[i], services)) + files = append(files, payloadBuilders(svc, data[i].CommandData, services)) } return files } // endpointParser returns the file that implements the command line parser that // builds the client endpoint and payload necessary to perform a request. -func endpointParser(genpkg string, services *ServicesData, svr *expr.ServerExpr, data []*cli.CommandData) *codegen.File { - pkg := codegen.SnakeCase(codegen.Goify(svr.Name, true)) +func endpointParser(services *ServicesData, serverPlan *grpcCLIServerPlan, data []*commandData) *codegen.File { + genpkg := services.GenPkg() + pkg := codegen.SnakeCase(codegen.Goify(serverPlan.name, true)) + outputPackage := path.Join(genpkg, "grpc", "cli", pkg) fpath := filepath.Join(codegen.Gendir, "grpc", "cli", pkg, "cli.go") - title := svr.Name + " gRPC client CLI support package" + title := serverPlan.name + " gRPC client CLI support package" specs := []*codegen.ImportSpec{ {Path: "context"}, {Path: "flag"}, @@ -63,8 +89,9 @@ func endpointParser(genpkg string, services *ServicesData, svr *expr.ServerExpr, } // Add structpb import if Any type is used needsAnyPb := false - for _, svc := range services.Root.API.GRPC.Services { - if usesAnyType(svc.GRPCEndpoints, false) { + for _, serviceName := range serverPlan.expression.Services { + servicePlan := grpcServicePlanByName(services.servicePlans, serviceName) + if servicePlan != nil && servicePlan.usesAny { needsAnyPb = true break } @@ -74,43 +101,89 @@ func endpointParser(genpkg string, services *ServicesData, svr *expr.ServerExpr, &codegen.ImportSpec{Path: "google.golang.org/protobuf/types/known/structpb", Name: "structpb"}, ) } - for _, svc := range services.Root.API.GRPC.Services { + for _, serviceName := range serverPlan.expression.Services { + servicePlan := grpcServicePlanByName(services.servicePlans, serviceName) + if servicePlan == nil { + continue + } + svc := servicePlan.expression sd := services.Get(svc.Name()) if sd == nil { continue } svcName := sd.Service.PathName specs = append(specs, - &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", svcName, "client"), Name: sd.Service.PkgName + "c"}, - &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: svcName + pbPkgName}) + services.PackageImport(outputPackage, path.Join(genpkg, "grpc", svcName, "client")), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName))) // Add interceptors import if service has client interceptors if len(sd.Service.ClientInterceptors) > 0 { - specs = append(specs, &codegen.ImportSpec{ - Path: genpkg + "/" + sd.Service.PathName, - Name: sd.Service.PkgName, - }) + specs = append(specs, services.ServiceImport(outputPackage, svc.Name())) } } + parser := serverPlan.parser + if parser == nil { + panic(fmt.Sprintf("gRPC command parser names are missing for server %q", serverPlan.name)) + } + plannedData := make([]*commandData, len(data)) + plannedCommands := make([]*cli.CommandData, len(data)) + for index, command := range data { + commandNames := parser.Commands[command.ServiceName] + if commandNames == nil { + panic(fmt.Sprintf("gRPC command names are missing for service %q", command.ServiceName)) + } + commandCopy := *command.CommandData + sd := services.Get(command.ServiceName) + clientPath := path.Join(genpkg, "grpc", sd.Service.PathName, "client") + commandCopy.PkgName = services.PackageImport(outputPackage, clientPath).Name + if command.Interceptors != nil { + interceptors := *command.Interceptors + interceptors.PkgName = services.ServiceImport(outputPackage, command.ServiceName).Name + commandCopy.Interceptors = &interceptors + } + commandCopy.UsageDeclaration = commandNames.Usage + commandCopy.Subcommands = make([]*cli.SubcommandData, len(command.Subcommands)) + for methodIndex, subcommand := range command.Subcommands { + usage := commandNames.Methods[subcommand.MethodName] + if usage == nil { + panic(fmt.Sprintf("gRPC method help name is missing for %q.%q", command.ServiceName, subcommand.Name)) + } + subcommandCopy := *subcommand + subcommandCopy.UsageDeclaration = usage + commandCopy.Subcommands[methodIndex] = &subcommandCopy + } + plannedData[index] = &commandData{ + CommandData: &commandCopy, + ClientInit: command.ClientInit, + } + plannedCommands[index] = &commandCopy + } + parser.PlanVariables(plannedCommands, nil) parseSection := &codegen.SectionTemplate{ Name: "parse-endpoint-grpc", Source: grpcTemplates.Read(grpcParseEndpointT), Data: struct { - FlagsCode string - Commands []*cli.CommandData + Declaration *codegen.NameDeclaration + FlagsCode string + Commands []*commandData + Variables *cli.ParserVariablesData }{ - cli.FlagsCode(data), - data, + parser.Declarations.ParseEndpoint, + parser.FlagsCode(plannedCommands), + plannedData, + parser.Variables, }, } - return cli.EndpointParserFile(fpath, title, specs, data, parseSection) + return parser.EndpointParserFile(fpath, title, specs, plannedCommands, parseSection) } // payloadBuilders returns the file that contains the payload constructors that // use flag values as arguments. -func payloadBuilders(genpkg string, svc *expr.GRPCServiceExpr, data *cli.CommandData, services *ServicesData) *codegen.File { +func payloadBuilders(servicePlan *grpcServicePlan, data *cli.CommandData, services *ServicesData) *codegen.File { + svc := servicePlan.expression sd := services.Get(svc.Name()) svcName := sd.Service.PathName + outputPackage := path.Join(services.GenPkg(), "grpc", svcName, "client") fpath := filepath.Join(codegen.Gendir, "grpc", svcName, "client", "cli.go") title := svc.Name() + " gRPC client CLI support package" specs := []*codegen.ImportSpec{ @@ -119,21 +192,29 @@ func payloadBuilders(genpkg string, svc *expr.GRPCServiceExpr, data *cli.Command {Path: "strconv"}, {Path: "unicode/utf8"}, codegen.GoaImport(""), - {Path: path.Join(genpkg, svcName), Name: sd.Service.PkgName}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: sd.PkgName}, + services.ServiceImport(outputPackage, svc.Name()), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + {Path: "google.golang.org/protobuf/encoding/protojson"}, } // Add structpb import if Any type is used - if usesAnyType(svc.GRPCEndpoints, false) { + if servicePlan.usesAny { specs = append(specs, &codegen.ImportSpec{Path: "google.golang.org/protobuf/types/known/structpb", Name: "structpb"}, ) } - return cli.PayloadBuildersFile(fpath, title, specs, data) + return addEndpointImports(cli.PayloadBuildersFile(fpath, title, specs, data), services, servicePlan) } -func buildFlags(e *EndpointData) ([]*cli.FlagData, *cli.BuildFunctionData) { +func buildFlags(e *EndpointData, declaration *codegen.NameDeclaration) ([]*cli.FlagData, *cli.BuildFunctionData) { if e.Request != nil { - return makeFlags(e, e.Request.CLIArgs) + flags, buildFunction := makeFlags(e, e.Request.CLIArgs) + if buildFunction != nil { + if declaration == nil { + panic(fmt.Sprintf("gRPC payload builder name is missing for %q.%q", e.ServiceName, e.Method.Name)) + } + buildFunction.Name = declaration.Name() + } + return flags, buildFunction } return nil, nil } @@ -143,28 +224,30 @@ func makeFlags(e *EndpointData, args []*InitArgData) ([]*cli.FlagData, *cli.Buil pInitArgs := make([]*codegen.InitArgData, len(args)) for i, arg := range args { pInitArgs[i] = &codegen.InitArgData{ - Name: arg.Name, - FieldName: arg.FieldName, - FieldType: arg.FieldType, - Type: arg.Type, + Name: arg.Name, + FieldName: arg.FieldName, + FieldType: arg.FieldType, + Type: arg.Type, + Pointer: arg.Pointer, + FieldPointer: arg.Pointer, } fargs[i] = &cli.FlagArgData{ Name: arg.Name, TypeName: arg.TypeName, + Plan: arg.CLIPlan, TypeRef: arg.TypeRef, FieldName: arg.FieldName, Description: arg.Description, Required: arg.Required, Example: arg.Example, DefaultValue: arg.DefaultValue, - Validate: arg.Validate, } } var pinit *cli.PayloadInitData if e.Method.PayloadRef != "" && e.Request.ServerConvert != nil { pinit = &cli.PayloadInitData{ - Code: e.Request.ServerConvert.Init.Code, + Code: e.Request.CLIInitCode, ReturnIsStruct: e.Request.ServerConvert.Init.ReturnIsStruct, ReturnTypePkg: e.Request.ServerConvert.Init.ReturnTypePkg, Args: pInitArgs, diff --git a/grpc/codegen/client_cli_test.go b/grpc/codegen/client_cli_test.go index c2b906c865..7568ebc7b6 100644 --- a/grpc/codegen/client_cli_test.go +++ b/grpc/codegen/client_cli_test.go @@ -2,12 +2,13 @@ package codegen import ( "bytes" - "goa.design/goa/v3/codegen/testutil" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/grpc/codegen/testdata" ) @@ -17,13 +18,14 @@ func TestClientCLIFiles(t *testing.T) { DSL func() }{ {"payload-with-validations", testdata.PayloadWithValidationsDSL}, + {"payload-with-message", testdata.PayloadWithMessageDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientCLIFiles("", services) + fs := clientCLIFiles(services) require.Greater(t, len(fs), 1, "expected at least 2 files") require.NotEmpty(t, fs[1].SectionTemplates) var buf bytes.Buffer @@ -35,3 +37,37 @@ func TestClientCLIFiles(t *testing.T) { }) } } + +// TestReleasedGRPCNamesMatchDeclarations verifies released plugins can read +// final method and validation names without choosing those names themselves. +func TestReleasedGRPCNamesMatchDeclarations(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithValidationsDSL) + services := CreateGRPCServices(root) + files := clientCLIFiles(services) + require.Greater(t, len(files), 1) + + build, ok := files[1].SectionTemplates[1].Data.(*cli.BuildFunctionData) + require.True(t, ok) + servicePlan := services.servicePlans[0] + declaration := services.cliPlan.builders[servicePlan.expression.GRPCEndpoints[0]] + require.NotNil(t, declaration) + require.Equal(t, declaration.Name(), build.Name) + + service := services.GRPCServices["PayloadWithValidation"] + require.NotNil(t, service) + require.NotEmpty(t, service.Endpoints) + endpoint := service.Endpoints[0] + require.Equal(t, endpoint.ProtoMethodName, endpoint.ClientMethodName) + + validationRoot := RunGRPCDSL(t, testdata.ElemValidationDSL) + validationService := CreateGRPCServices(validationRoot).GRPCServices["ServiceElemValidation"] + require.NotNil(t, validationService) + require.NotEmpty(t, validationService.Endpoints) + request := validationService.Endpoints[0].Request + require.NotNil(t, request) + require.NotNil(t, request.ServerConvert) + validation := request.ServerConvert.Validation + require.NotNil(t, validation) + require.NotNil(t, validation.Declaration) + require.Equal(t, validation.Declaration.Name(), validation.Name) +} diff --git a/grpc/codegen/client_test.go b/grpc/codegen/client_test.go index 464056963f..55f965f747 100644 --- a/grpc/codegen/client_test.go +++ b/grpc/codegen/client_test.go @@ -32,7 +32,7 @@ func TestClientEndpointInit(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientFiles("", services) + fs := clientFiles(services) require.Len(t, fs, 2) sections := fs[0].Section("client-endpoint-init") if len(sections) == 0 { @@ -64,7 +64,7 @@ func TestRequestEncoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientFiles("", services) + fs := clientFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("request-encoder") require.NotEmpty(t, sections) @@ -95,7 +95,7 @@ func TestResponseDecoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientFiles("", services) + fs := clientFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("response-decoder") require.NotEmpty(t, sections) diff --git a/grpc/codegen/client_types_test.go b/grpc/codegen/client_types_test.go index 4a65fb5039..b1d315a5f7 100644 --- a/grpc/codegen/client_types_test.go +++ b/grpc/codegen/client_types_test.go @@ -1,16 +1,147 @@ +// This file checks the generated client and server conversion functions. package codegen import ( "bytes" - "goa.design/goa/v3/codegen/testutil" + "strings" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/grpc/codegen/testdata" ) +// TestTypeFilesShareRepeatedConversions checks that two endpoints using the +// same payload call one conversion function in each generated package. +func TestTypeFilesShareRepeatedConversions(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithMultipleUseTypesDSL) + services := CreateGRPCServices(root) + client := codegen.SectionsCode(t, clientTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Equal(t, 1, strings.Count(client, "func NewProtoDupePayload(")) + require.Equal(t, 1, strings.Count(server, "func NewDupePayload(")) + require.NotContains(t, server, "func NewMethodPayloadDuplicateAPayload(") + require.NotContains(t, server, "func NewMethodPayloadDuplicateBPayload(") +} + +// TestCLIConversionsShareTypePair checks that command-line payload builders +// for the same protobuf message and Goa type keep the same conversion plan. +func TestCLIConversionsShareTypePair(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithMultipleUseTypesDSL) + services := CreateGRPCServices(root) + grpcService := root.API.GRPC.Services[0] + first := services.symbols[grpcService].endpoints[grpcService.GRPCEndpoints[0]].cliPayload + second := services.symbols[grpcService].endpoints[grpcService.GRPCEndpoints[1]].cliPayload + + require.Same(t, first, second) +} + +// TestRequestMetadataKeepsDistinctConversions checks that the same Goa type +// gets separate constructors when endpoint metadata produces different +// protobuf messages and different function arguments. +func TestRequestMetadataKeepsDistinctConversions(t *testing.T) { + root := RunGRPCDSL(t, func() { + payload := dsl.Type("SharedPayload", func() { + dsl.Field(1, "value", dsl.String) + dsl.Field(2, "token", dsl.String) + }) + dsl.Service("MetadataConversions", func() { + dsl.Method("Plain", func() { + dsl.Payload(payload) + dsl.GRPC(func() {}) + }) + dsl.Method("WithMetadata", func() { + dsl.Payload(payload) + dsl.GRPC(func() { + dsl.Metadata(func() { + dsl.Attribute("token") + }) + }) + }) + }) + }) + services := CreateGRPCServices(root) + grpcService := root.API.GRPC.Services[0] + plain := services.symbols[grpcService].endpoints[grpcService.GRPCEndpoints[0]].serverInits[grpcInitKey{role: grpcRequestInit}] + metadata := services.symbols[grpcService].endpoints[grpcService.GRPCEndpoints[1]].serverInits[grpcInitKey{role: grpcRequestInit}] + + require.NotSame(t, plain, metadata) + require.NotSame(t, plain.declaration, metadata.declaration) +} + +// TestOneUseConversionsKeepReleasedNames checks that conversions used by one +// method keep the names generated by released Goa versions. +func TestOneUseConversionsKeepReleasedNames(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithMixedAttributesDSL) + services := CreateGRPCServices(root) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Contains(t, server, "func NewUnaryMethodPayload(") + require.Contains(t, server, "func NewStreamingMethodStreamingRequestAPayload(") + require.NotContains(t, server, "func NewAPayloadFromProto") +} + +// TestReleasedConversionNameCollisionsUseDeclaredNames checks that two old +// names which become equal get stable suffixes in definitions and calls. +func TestReleasedConversionNameCollisionsUseDeclaredNames(t *testing.T) { + root := RunGRPCDSL(t, func() { + first := dsl.Type("FirstPayload", func() { + dsl.Field(1, "first", dsl.String) + }) + second := dsl.Type("SecondPayload", func() { + dsl.Field(1, "second", dsl.String) + }) + dsl.Service("CollidingConversions", func() { + dsl.Method("foo-bar", func() { + dsl.Payload(first) + dsl.GRPC(func() {}) + }) + dsl.Method("foo_bar", func() { + dsl.Payload(second) + dsl.GRPC(func() {}) + }) + }) + }) + services := CreateGRPCServices(root) + serverTypes := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverFiles(services)[1].Section("request-decoder")) + + require.Equal(t, 1, strings.Count(serverTypes, "func NewFooBarPayload(")) + require.Equal(t, 1, strings.Count(serverTypes, "func NewFooBarPayload2(")) + require.Contains(t, server, "NewFooBarPayload(") + require.Contains(t, server, "NewFooBarPayload2(") +} + +// TestLegacyMetadataConversionKeepsReleasedName checks that legacy request +// metadata conversion keeps its released method-specific name. +func TestLegacyMetadataConversionKeepsReleasedName(t *testing.T) { + root := RunGRPCDSL(t, testdata.BidirectionalStreamingRPCWithPayloadLegacyCompatDSL) + services := CreateGRPCServices(root) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Contains(t, server, "func NewMethodBidirectionalStreamingRPCWithPayloadLegacyCompatPayloadFromMetadata(") +} + +// TestTransformHelperNamesDescribeNestedTypes checks that each helper name +// identifies the nested value it converts and its direction. +func TestTransformHelperNamesDescribeNestedTypes(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithNestedTypesDSL) + services := CreateGRPCServices(root) + client := codegen.SectionsCode(t, clientTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Contains(t, client, "func transformAParamsToProtoAParams(") + require.Contains(t, client, "func transformBParamsToProtoBParams(") + require.Contains(t, server, "func transformProtoAParamsToAParams(") + require.Contains(t, server, "func transformProtoBParamsToBParams(") + require.NotRegexp(t, `func transform\w+\d+\(`, client) + require.NotRegexp(t, `func transform\w+\d+\(`, server) +} + func TestClientTypeFiles(t *testing.T) { cases := []struct { Name string @@ -26,12 +157,15 @@ func TestClientTypeFiles(t *testing.T) { {"client-struct-meta-type", testdata.StructMetaTypeDSL}, {"client-struct-field-name-meta-type", testdata.StructFieldNameMetaTypeDSL}, {"client-default-fields", testdata.DefaultFieldsDSL}, + {"client-result-with-views", testdata.MessageResultTypeWithViewsDSL}, + {"client-result-with-explicit-view", testdata.MessageResultTypeWithExplicitViewDSL}, + {"client-streaming-result-with-views", testdata.ServerStreamingResultWithViewsDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientTypeFiles("", services) + fs := clientTypeFiles(services) require.Len(t, fs, 1) var buf bytes.Buffer for _, s := range fs[0].SectionTemplates[1:] { diff --git a/grpc/codegen/compatibility.go b/grpc/codegen/compatibility.go new file mode 100644 index 0000000000..beffc37d10 --- /dev/null +++ b/grpc/codegen/compatibility.go @@ -0,0 +1,59 @@ +// This file keeps released gRPC generator entry points available to plugins +// while all rendering uses the one service plan retained by Goa. +package codegen + +import ( + "fmt" + + "goa.design/goa/v3/codegen" +) + +// ClientFiles returns the planned client files. genpkg must match the package +// used to create services. +func ClientFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return clientFiles(services) +} + +// ClientCLIFiles returns the planned command-line client files. genpkg must +// match the package used to create services. +func ClientCLIFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return clientCLIFiles(services) +} + +// ProtoFiles returns the planned protobuf files. genpkg must match the package +// used to create services. +func ProtoFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return protoFiles(services) +} + +// ServerFiles returns the planned server files. genpkg must match the package +// used to create services. +func ServerFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return serverFiles(services) +} + +// ServerTypeFiles returns the planned server conversion files. genpkg must +// match the package used to create services. +func ServerTypeFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return serverTypeFiles(services) +} + +// ClientTypeFiles returns the planned client conversion files. genpkg must +// match the package used to create services. +func ClientTypeFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return clientTypeFiles(services) +} + +// requireGeneratedPackage rejects a package argument that does not describe +// the service data supplied by the same generation run. +func requireGeneratedPackage(genpkg string, services *ServicesData) { + if genpkg != services.GenPkg() { + panic(fmt.Sprintf("gRPC generation package %q does not match planned package %q", genpkg, services.GenPkg())) + } +} diff --git a/grpc/codegen/compatibility_test.go b/grpc/codegen/compatibility_test.go new file mode 100644 index 0000000000..7fb4f18d1b --- /dev/null +++ b/grpc/codegen/compatibility_test.go @@ -0,0 +1,39 @@ +// This file pins released gRPC generator entry points that plugins call after +// Goa has built the service data for one generated package. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +var ( + _ func(string, *ServicesData) []*codegen.File = ClientFiles + _ func(string, *ServicesData) []*codegen.File = ClientCLIFiles + _ func(string, *ServicesData) []*codegen.File = ProtoFiles + _ func(string, *ServicesData) []*codegen.File = ServerFiles + _ func(string, *ServicesData) []*codegen.File = ServerTypeFiles + _ func(string, *ServicesData) []*codegen.File = ClientTypeFiles +) + +// TestReleasedFileFunctionsUsePlannedPackage checks that the compatibility +// entry points render one retained plan and reject a different package. +func TestReleasedFileFunctionsUsePlannedPackage(t *testing.T) { + services := CreateGRPCServices(RunGRPCDSL(t, testdata.UnaryRPCsDSL)) + genpkg := services.GenPkg() + require.Len(t, ClientFiles(genpkg, services), len(clientFiles(services))) + require.Len(t, ClientCLIFiles(genpkg, services), len(clientCLIFiles(services))) + require.Len(t, ProtoFiles(genpkg, services), len(protoFiles(services))) + require.Len(t, ServerFiles(genpkg, services), len(serverFiles(services))) + require.Len(t, ServerTypeFiles(genpkg, services), len(serverTypeFiles(services))) + require.Len(t, ClientTypeFiles(genpkg, services), len(clientTypeFiles(services))) + require.PanicsWithValue( + t, + `gRPC generation package "other.local/gen" does not match planned package "generated.local/gen"`, + func() { ClientFiles("other.local/gen", services) }, + ) +} diff --git a/grpc/codegen/example_cli.go b/grpc/codegen/example_cli.go index c06c228701..4ac8c683dd 100644 --- a/grpc/codegen/example_cli.go +++ b/grpc/codegen/example_cli.go @@ -1,57 +1,76 @@ +// This file writes runnable gRPC command-line examples with the package names +// already chosen for this generation. package codegen import ( - "os" "path" "path/filepath" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) -// ExampleCLIFiles returns an example gRPC client tool implementation. -func ExampleCLIFiles(genpkg string, services *ServicesData) []*codegen.File { +// exampleCLIFiles returns an example gRPC client tool implementation. +func exampleCLIFiles(root *example.Root, services *ServicesData) []*codegen.File { var files []*codegen.File - for _, svr := range services.Root.API.Servers { - if f := exampleCLI(genpkg, services, svr); f != nil { + for _, server := range root.Servers { + if f := exampleCLI(services, server); f != nil { files = append(files, f) } } return files } -// exampleCLI returns an example client tool HTTP implementation for the given -// server expression. -func exampleCLI(genpkg string, services *ServicesData, svr *expr.ServerExpr) *codegen.File { - svrdata := example.Servers.Get(svr, services.Root) - mainPath := filepath.Join("cmd", svrdata.Dir+"-cli", "grpc.go") - if _, err := os.Stat(mainPath); !os.IsNotExist(err) { - return nil // file already exists, skip it. +// exampleCLI writes the gRPC command-line program for server. +func exampleCLI(services *ServicesData, server *example.Data) *codegen.File { + genpkg := services.GenPkg() + mainPath := filepath.Join("cmd", server.Dir+"-cli", "grpc.go") + rootPath := path.Dir(genpkg) + outputPackage := path.Join(rootPath, "cmd", server.Dir+"-cli") + cliImport := services.PackageImport(outputPackage, path.Join(genpkg, "grpc", "cli", server.Dir)) + parser := services.cliPlan.parser(server.Name) + if parser == nil { + panic("gRPC command parser names are missing for server " + server.Name) } - rootPath := example.RootPath(genpkg) specs := []*codegen.ImportSpec{ {Path: "context"}, - {Path: "encoding/json"}, + {Path: "errors"}, {Path: "flag"}, {Path: "fmt"}, + {Path: "io"}, {Path: "google.golang.org/grpc"}, {Path: "google.golang.org/grpc/credentials/insecure"}, {Path: "os"}, {Path: "time"}, codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), - {Path: rootPath + "/interceptors"}, - {Path: path.Join(genpkg, "grpc", "cli", svrdata.Dir), Name: "cli"}, + cliImport, } var svcData []*ServiceData - for _, svc := range svr.Services { + hasClientInterceptors := false + for _, svc := range server.Services { if data := services.Get(svc); data != nil { - svcData = append(svcData, data) + svcData = append(svcData, services.exampleServiceData(data, outputPackage, false)) + hasClientInterceptors = hasClientInterceptors || len(data.Service.ClientInterceptors) > 0 + for _, endpoint := range data.Endpoints { + if cliStreamsOutput(endpoint.Method) { + specs = append(specs, services.ServiceImport(outputPackage, svc)) + break + } + } } } + var interceptorsPkg string + if hasClientInterceptors { + interceptorImport := services.PackageImport(outputPackage, rootPath+"/interceptors") + interceptorsPkg = interceptorImport.Name + specs = append(specs, interceptorImport) + } sections := []*codegen.SectionTemplate{ codegen.Header("", "main", specs), @@ -59,12 +78,89 @@ func exampleCLI(genpkg string, services *ServicesData, svr *expr.ServerExpr) *co Name: "do-grpc-cli", Source: grpcTemplates.Read(grpcDoGRPCCLIT), Data: map[string]any{ - "DefaultTransport": svrdata.DefaultTransport(), + "DefaultTransport": server.DefaultTransport(), "Services": svcData, - "InterceptorsPkg": "interceptors", + "InterceptorsPkg": interceptorsPkg, + "CLIPkg": cliImport.Name, + "Parser": parser.Declarations, + }, + FuncMap: map[string]any{ + "hasAnyInputStreams": cliHasAnyInputStreams, + "hasInputStreams": cliHasInputStreams, + "hasRunnable": cliHasRunnableCommands, + "hasRunnableService": cliHasRunnableService, + "kebab": codegen.KebabCase, + "streamsInput": cliStreamsInput, + "streamsOutput": cliStreamsOutput, }, }, } return &codegen.File{Path: mainPath, SectionTemplates: sections, SkipExist: true} } + +// cliStreamsInput reports whether an example command would need to send more +// payload values after the endpoint call starts. +func cliStreamsInput(method *service.MethodData) bool { + return method.StreamKind == expr.ClientStreamKind || method.StreamKind == expr.BidirectionalStreamKind +} + +// cliStreamsOutput reports whether an example command receives a sequence of +// results from the server. +func cliStreamsOutput(method *service.MethodData) bool { + return method.StreamKind == expr.ServerStreamKind +} + +// cliHasInputStreams reports whether a service has commands that the example +// client must reject before parsing an endpoint. +func cliHasInputStreams(data *ServiceData) bool { + for _, endpoint := range data.Endpoints { + if cliStreamsInput(endpoint.Method) { + return true + } + } + return false +} + +// cliHasAnyInputStreams reports whether any service has a command that the +// example client must reject before parsing an endpoint. +func cliHasAnyInputStreams(services []*ServiceData) bool { + for _, data := range services { + if cliHasInputStreams(data) { + return true + } + } + return false +} + +// cliHasRunnableCommands reports whether the example client can invoke at +// least one generated endpoint. +func cliHasRunnableCommands(services []*ServiceData) bool { + for _, data := range services { + if cliHasRunnableService(data) { + return true + } + } + return false +} + +// cliHasRunnableService reports whether the example client can invoke at +// least one endpoint in the service. +func cliHasRunnableService(data *ServiceData) bool { + for _, endpoint := range data.Endpoints { + if !cliStreamsInput(endpoint.Method) { + return true + } + } + return false +} + +// parser returns the command parser saved for the named server. +func (p *grpcCLIPlan) parser(serverName string) *cli.ParserPlan { + for _, server := range p.servers { + if server.name == serverName { + return server.parser + } + } + return nil +} diff --git a/grpc/codegen/example_cli_test.go b/grpc/codegen/example_cli_test.go index 1862de04b1..e5c8c2f70d 100644 --- a/grpc/codegen/example_cli_test.go +++ b/grpc/codegen/example_cli_test.go @@ -1,3 +1,4 @@ +// This file verifies generated gRPC command-line client examples. package codegen import ( @@ -7,9 +8,7 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/grpc/codegen/testdata" ) @@ -20,21 +19,22 @@ func TestExampleCLIFiles(t *testing.T) { DSL func() PkgPath string }{ - {"no-server", ctestdata.NoServerDSL, ""}, - {"server-hosting-service-subset", ctestdata.ServerHostingServiceSubsetDSL, ""}, - {"server-hosting-multiple-services", ctestdata.ServerHostingMultipleServicesDSL, ""}, + {"no-server", ctestdata.NoServerDSL, "generated.local/gen"}, + {"server-hosting-service-subset", ctestdata.ServerHostingServiceSubsetDSL, "generated.local/gen"}, + {"server-hosting-multiple-services", ctestdata.ServerHostingMultipleServicesDSL, "generated.local/gen"}, {"no-server-pkgpath", ctestdata.NoServerDSL, "my/pkg/path"}, {"server-hosting-service-subset-pkgpath", ctestdata.ServerHostingServiceSubsetDSL, "my/pkg/path"}, {"server-hosting-multiple-services-pkgpath", ctestdata.ServerHostingMultipleServicesDSL, "my/pkg/path"}, - {"interceptors", testdata.InterceptorsDSL, ""}, + {"interceptors", testdata.InterceptorsDSL, "generated.local/gen"}, + {"server-streaming", testdata.ServerStreamingRPCDSL, "generated.local/gen"}, + {"client-streaming", testdata.ClientStreamingRPCDSL, "generated.local/gen"}, + {"bidirectional-streaming", testdata.BidirectionalStreamingRPCDSL, "generated.local/gen"}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(service.NewServicesData(root)) - fs := ExampleCLIFiles(c.PkgPath, services) + examples := createExamplePlan(root, c.PkgPath) + fs := examples.CLIFiles() require.Greater(t, len(fs), 0) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer diff --git a/grpc/codegen/example_identity_test.go b/grpc/codegen/example_identity_test.go new file mode 100644 index 0000000000..a768e4c00d --- /dev/null +++ b/grpc/codegen/example_identity_test.go @@ -0,0 +1,12 @@ +// This file supplies exact semantic owners to protobuf-shaping unit tests. +package codegen + +import "goa.design/goa/v3/expr" + +// testGRPCMessageExampleIdentity returns a distinct request-message owner for +// the named test case without introducing production fallback identity rules. +func testGRPCMessageExampleIdentity(name string) expr.ExampleIdentity { + service := &expr.ServiceExpr{Name: "test"} + method := &expr.MethodExpr{Name: name, Service: service} + return expr.GRPCRequestMessageExampleIdentity(method) +} diff --git a/grpc/codegen/example_server.go b/grpc/codegen/example_server.go index c2d5bcc417..607c6efcb0 100644 --- a/grpc/codegen/example_server.go +++ b/grpc/codegen/example_server.go @@ -1,20 +1,20 @@ +// This file writes runnable gRPC servers with the package names already chosen +// for this generation. package codegen import ( - "os" "path" "path/filepath" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/expr" ) -// ExampleServerFiles returns an example gRPC server implementation. -func ExampleServerFiles(genpkg string, services *ServicesData) []*codegen.File { +// exampleServerFiles returns an example gRPC server implementation. +func exampleServerFiles(root *example.Root, services *ServicesData) []*codegen.File { var fw []*codegen.File - for _, svr := range services.Root.API.Servers { - if m := exampleServer(genpkg, services, svr); m != nil { + for _, server := range root.Servers { + if m := exampleServer(services, server); m != nil { fw = append(fw, m) } } @@ -22,18 +22,13 @@ func ExampleServerFiles(genpkg string, services *ServicesData) []*codegen.File { } // exampleServer returns an example gRPC server implementation. -func exampleServer(genpkg string, services *ServicesData, svr *expr.ServerExpr) *codegen.File { +func exampleServer(services *ServicesData, server *example.Data) *codegen.File { var ( mainPath string - - svrdata = example.Servers.Get(svr, services.Root) + genpkg = services.GenPkg() ) - mainPath = filepath.Join("cmd", svrdata.Dir, "grpc.go") - if _, err := os.Stat(mainPath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } - - var scope = codegen.NewNameScope() + mainPath = filepath.Join("cmd", server.Dir, "grpc.go") + outputPackage := path.Join(path.Dir(genpkg), "cmd", server.Dir) specs := []*codegen.ImportSpec{ {Path: "context"}, @@ -47,35 +42,29 @@ func exampleServer(genpkg string, services *ServicesData, svr *expr.ServerExpr) {Path: "google.golang.org/grpc"}, {Path: "google.golang.org/grpc/reflection"}, } - for _, svc := range services.Root.API.GRPC.Services { - sd := services.Get(svc.Name()) + for _, serviceName := range server.Services { + sd := services.Get(serviceName) + if sd == nil { + continue + } svcName := sd.Service.PathName - specs = append(specs, - &codegen.ImportSpec{ - Path: path.Join(genpkg, "grpc", svcName, "server"), - Name: scope.Unique(sd.Service.PkgName + "svr"), - }, - &codegen.ImportSpec{ - Path: path.Join(genpkg, svcName), - Name: scope.Unique(sd.Service.PkgName), - }, - &codegen.ImportSpec{ - Path: path.Join(genpkg, "grpc", svcName, pbPkgName), - Name: scope.Unique(svcName + pbPkgName), - }) + serverImport := services.PackageImport(outputPackage, path.Join(genpkg, "grpc", svcName, "server")) + serviceImport := services.ServiceImport(outputPackage, serviceName) + protobufImport := services.PackageImport(outputPackage, path.Join(genpkg, "grpc", svcName, pbPkgName)) + specs = append(specs, serverImport, serviceImport, protobufImport) } - rootPath := example.RootPath(genpkg) - apiPkg := example.APIPkg(services.Root, scope) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: apiPkg}) + rootPath := path.Dir(genpkg) + apiImport := services.PackageImport(outputPackage, rootPath) + specs = append(specs, apiImport) var ( sections []*codegen.SectionTemplate ) var svcdata []*ServiceData - for _, svc := range svr.Services { + for _, svc := range server.Services { if data := services.Get(svc); data != nil { - svcdata = append(svcdata, data) + svcdata = append(svcdata, services.exampleServiceData(data, outputPackage, true)) } } sections = []*codegen.SectionTemplate{ diff --git a/grpc/codegen/example_server_test.go b/grpc/codegen/example_server_test.go index 7d553a3b5e..d83a9abeed 100644 --- a/grpc/codegen/example_server_test.go +++ b/grpc/codegen/example_server_test.go @@ -1,15 +1,15 @@ +// This file verifies generated gRPC server examples. package codegen import ( "bytes" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" ) @@ -24,11 +24,9 @@ func TestExampleServerFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(service.NewServicesData(root)) - fs := ExampleServerFiles("", services) + examples := createExamplePlan(root, "generated.local/gen") + fs := examples.ServerFiles() require.Greater(t, len(fs), 0) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -36,6 +34,12 @@ func TestExampleServerFiles(t *testing.T) { require.NoError(t, s.Write(&buf)) } code := codegen.FormatTestCode(t, "package foo\n"+buf.String()) + if strings.Contains(code, "GetServiceInfo") { + t.Errorf("generated server discovers methods at runtime:\n%s", code) + } + if !strings.Contains(code, "serving gRPC method") { + t.Errorf("generated server does not log its planned methods:\n%s", code) + } golden := filepath.Join("testdata", "server-"+c.Name+".golden") testutil.AssertGo(t, golden, code) }) diff --git a/grpc/codegen/idempotency_test.go b/grpc/codegen/idempotency_test.go index 437775b746..af6a9089f9 100644 --- a/grpc/codegen/idempotency_test.go +++ b/grpc/codegen/idempotency_test.go @@ -15,14 +15,14 @@ func TestIdempotentRPCCodegen(t *testing.T) { root := RunGRPCDSL(t, testdata.IdempotentRPCsDSL) services := CreateGRPCServices(root) - protoFiles := ProtoFiles("", services) + protoFiles := protoFiles(services) require.Len(t, protoFiles, 1) protoCode := sectionCode(t, protoFiles[0].SectionTemplates[1:]...) assert.Equal(t, 2, strings.Count(protoCode, "option idempotency_level = IDEMPOTENT;")) protoPath := codegen.CreateTempFile(t, protoCode) - assert.NoError(t, protoc(defaultProtocCmd, protoPath, nil)) + assert.NoError(t, protoc(defaultProtocCmd, protoPath)) - clientFiles := ClientFiles("", services) + clientFiles := clientFiles(services) require.Len(t, clientFiles, 2) clientCode := codegen.SectionsCode(t, clientFiles[0].Section("client-endpoint-init")) assert.Contains(t, clientCode, `goa.RetryEndpoint(endpoint, "busy")`) diff --git a/grpc/codegen/import_plan.go b/grpc/codegen/import_plan.go new file mode 100644 index 0000000000..00d877bbc0 --- /dev/null +++ b/grpc/codegen/import_plan.go @@ -0,0 +1,388 @@ +// This file records every import in the generated gRPC package that writes +// the reference. Package-local planning keeps an unrelated transport or +// executable from changing a gRPC qualifier. +package codegen + +import ( + "path" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/expr" +) + +// planGRPCImports records imports for each generated client, server, and +// command-parser package before Generation.Freeze chooses their names. +func planGRPCImports(generation *codegen.Generation, plan *Plan) error { + for _, servicePlan := range plan.servicesPlan { + service := servicePlan.expression + pathName := servicePlan.packages.pathName + clientPath := path.Join(generation.GenPkg(), "grpc", pathName, "client") + serverPath := path.Join(generation.GenPkg(), "grpc", pathName, "server") + protobufPath := path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName) + + client := generation.Package(clientPath) + clientFixed := []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("strconv"), + codegen.SimpleImport("unicode/utf8"), + codegen.GoaImport(""), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.GoaNamedImport("grpc/pb", "goapb"), + codegen.SimpleImport("google.golang.org/grpc"), + codegen.SimpleImport("google.golang.org/grpc/metadata"), + } + if len(service.GRPCEndpoints) > 0 { + clientFixed = append(clientFixed, + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("google.golang.org/protobuf/encoding/protojson"), + ) + } + if servicePlan.usesAny { + clientFixed = append(clientFixed, codegen.SimpleImport("google.golang.org/protobuf/types/known/structpb")) + } + if err := requirePackageImports(client, clientFixed); err != nil { + return err + } + if err := reservePackageImports(client, + servicePlan.packages.service, + codegen.NewImport(pathName+"pb", protobufPath), + ); err != nil { + return err + } + if grpcServiceHasViewedResult(service) { + if err := client.ReserveGeneratedImport(servicePlan.packages.views); err != nil { + return err + } + } + if err := planGRPCAttributeImports(client, generation, grpcEndpointAttributes(service.GRPCEndpoints...)); err != nil { + return err + } + if err := requirePackageImports(client, servicePlan.protoGoImports); err != nil { + return err + } + + server := generation.Package(serverPath) + serverFixed := []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("errors"), + codegen.SimpleImport("strconv"), + codegen.SimpleImport("strings"), + codegen.SimpleImport("unicode/utf8"), + codegen.GoaImport(""), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.SimpleImport("google.golang.org/grpc"), + codegen.SimpleImport("google.golang.org/grpc/codes"), + codegen.SimpleImport("google.golang.org/grpc/metadata"), + } + if grpcServiceStreamsPayload(service) { + serverFixed = append(serverFixed, codegen.SimpleImport("io")) + } + if grpcResponseMetadataUsesAny(service) { + serverFixed = append(serverFixed, codegen.SimpleImport("fmt")) + } + if servicePlan.usesAnyInErrors { + serverFixed = append(serverFixed, codegen.SimpleImport("google.golang.org/protobuf/types/known/structpb")) + } + if err := requirePackageImports(server, serverFixed); err != nil { + return err + } + if err := reservePackageImports(server, + servicePlan.packages.service, + codegen.NewImport(pathName+"pb", protobufPath), + ); err != nil { + return err + } + if grpcServiceHasViewedResult(service) { + if err := server.ReserveGeneratedImport(servicePlan.packages.views); err != nil { + return err + } + } + if err := planGRPCAttributeImports(server, generation, grpcEndpointAttributes(service.GRPCEndpoints...)); err != nil { + return err + } + if err := requirePackageImports(server, servicePlan.protoGoImports); err != nil { + return err + } + } + + for _, serverPlan := range plan.cli.servers { + serverName := codegen.SnakeCase(codegen.Goify(serverPlan.name, true)) + outputPath := path.Join(generation.GenPkg(), "grpc", "cli", serverName) + output := generation.Package(outputPath) + fixed := []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("flag"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("os"), + codegen.SimpleImport("strconv"), + codegen.SimpleImport("unicode/utf8"), + codegen.GoaImport(""), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.SimpleImport("google.golang.org/grpc"), + } + if grpcServerPlansUseAny(plan.servicesPlan, serverPlan.expression.Services) { + fixed = append(fixed, codegen.SimpleImport("google.golang.org/protobuf/types/known/structpb")) + } + if err := requirePackageImports(output, fixed); err != nil { + return err + } + for _, serviceName := range serverPlan.expression.Services { + servicePlan := grpcServicePlanByName(plan.servicesPlan, serviceName) + if servicePlan == nil { + continue + } + pathName := servicePlan.packages.pathName + if err := reservePackageImports(output, + codegen.NewImport(servicePlan.packages.service.Name+"c", path.Join(generation.GenPkg(), "grpc", pathName, "client")), + codegen.NewImport(pathName+"pb", path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName)), + ); err != nil { + return err + } + if len(servicePlan.source.ServiceExpr.ClientInterceptors) > 0 { + if err := output.ReserveGeneratedImport(servicePlan.packages.service); err != nil { + return err + } + } + } + } + return nil +} + +// planGRPCExampleImports adds the gRPC files' imports to the executable +// packages already claimed by the shared example planner. +func planGRPCExampleImports(generation *codegen.Generation, plan *Plan, root *example.Root) error { + rootPath := path.Dir(generation.GenPkg()) + for _, server := range root.Servers { + serverPath := path.Join(rootPath, "cmd", server.Dir) + serverPackage, err := generation.ClaimOutputPackage(serverPath, path.Join("cmd", server.Dir)) + if err != nil { + return err + } + if err := requirePackageImports(serverPackage, []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("net"), + codegen.SimpleImport("net/url"), + codegen.SimpleImport("sync"), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.SimpleImport("goa.design/clue/debug"), + codegen.SimpleImport("goa.design/clue/log"), + codegen.SimpleImport("google.golang.org/grpc"), + codegen.SimpleImport("google.golang.org/grpc/reflection"), + }); err != nil { + return err + } + for _, serviceName := range server.Services { + servicePlan := grpcServicePlanByName(plan.servicesPlan, serviceName) + if servicePlan == nil { + continue + } + pathName := servicePlan.packages.pathName + if err := reservePackageImports(serverPackage, + codegen.NewImport(servicePlan.packages.service.Name+"svr", path.Join(generation.GenPkg(), "grpc", pathName, "server")), + servicePlan.packages.service, + codegen.NewImport(pathName+"pb", path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName)), + ); err != nil { + return err + } + } + + if server.DefaultTransport() == nil { + continue + } + clientPath := path.Join(rootPath, "cmd", server.Dir+"-cli") + clientPackage, err := generation.ClaimOutputPackage(clientPath, path.Join("cmd", server.Dir+"-cli")) + if err != nil { + return err + } + if err := requirePackageImports(clientPackage, []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("errors"), + codegen.SimpleImport("flag"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("io"), + codegen.SimpleImport("os"), + codegen.SimpleImport("time"), + codegen.GoaImport(""), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.SimpleImport("google.golang.org/grpc"), + codegen.SimpleImport("google.golang.org/grpc/credentials/insecure"), + }); err != nil { + return err + } + if err := clientPackage.ReserveGeneratedImport(codegen.NewImport( + "cli", + path.Join(generation.GenPkg(), "grpc", "cli", server.Dir), + )); err != nil { + return err + } + for _, serviceName := range server.Services { + service := plan.root.API.GRPC.Service(serviceName) + if service == nil { + continue + } + if grpcServiceStreamsResult(service) { + if err := clientPackage.ReserveGeneratedImport(plan.packages[service].service); err != nil { + return err + } + } + servicePlan := grpcServicePlanByName(plan.servicesPlan, serviceName) + if servicePlan != nil && len(servicePlan.source.ServiceExpr.ClientInterceptors) > 0 { + if err := clientPackage.ReserveGeneratedImport(codegen.NewImport("interceptors", rootPath+"/interceptors")); err != nil { + return err + } + } + } + } + return nil +} + +func requirePackageImports(output *codegen.GeneratedPackage, imports []*codegen.ImportSpec) error { + for _, spec := range imports { + if err := output.RequireImport(spec); err != nil { + return err + } + } + return nil +} + +func reservePackageImports(output *codegen.GeneratedPackage, imports ...*codegen.ImportSpec) error { + for _, spec := range imports { + if err := output.ReserveGeneratedImport(spec); err != nil { + return err + } + } + return nil +} + +// planGRPCAttributeImports preserves authored metadata aliases while generated +// service types use a package-local preferred name. +func planGRPCAttributeImports(output *codegen.GeneratedPackage, generation *codegen.Generation, attributes []*expr.AttributeExpr) error { + seen := make(map[expr.UserType]struct{}) + var walk func(*expr.AttributeExpr) error + walk = func(attribute *expr.AttributeExpr) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + if _, spec := codegen.GetMetaType(attribute); spec != nil && spec.Path != output.ImportPath() { + if err := output.DeclareImport(spec); err != nil { + return err + } + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if location := codegen.UserTypeLocation(actual); location != nil { + importPath := path.Join(generation.GenPkg(), location.RelImportPath) + if importPath != output.ImportPath() { + preferred := strings.ToLower(codegen.Goify(path.Base(importPath), false)) + if err := output.ReserveGeneratedImport(codegen.NewImport(preferred, importPath)); err != nil { + return err + } + } + } + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return nil + } + seen[origin] = struct{}{} + return walk(actual.Attribute()) + case *expr.Object: + for _, named := range *actual { + if err := walk(named.Attribute); err != nil { + return err + } + } + case *expr.Array: + return walk(actual.ElemType) + case *expr.Map: + if err := walk(actual.KeyType); err != nil { + return err + } + return walk(actual.ElemType) + case *expr.Union: + for _, named := range actual.Values { + if err := walk(named.Attribute); err != nil { + return err + } + } + } + return nil + } + for _, attribute := range attributes { + if err := walk(attribute); err != nil { + return err + } + } + return nil +} + +func grpcServiceHasViewedResult(service *expr.GRPCServiceExpr) bool { + for _, endpoint := range service.GRPCEndpoints { + if _, ok := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr); ok { + return true + } + } + return false +} + +func grpcServiceStreamsPayload(service *expr.GRPCServiceExpr) bool { + for _, endpoint := range service.GRPCEndpoints { + if endpoint.MethodExpr.IsPayloadStreaming() && !isEmpty(endpoint.Request.Type) { + return true + } + } + return false +} + +func grpcServiceStreamsResult(service *expr.GRPCServiceExpr) bool { + for _, endpoint := range service.GRPCEndpoints { + if endpoint.MethodExpr.IsResultStreaming() && !endpoint.MethodExpr.IsPayloadStreaming() { + return true + } + } + return false +} + +// grpcServerPlansUseAny reports whether one server's generated command parser +// handles a service with protobuf Any fields. +func grpcServerPlansUseAny(services []*grpcServicePlan, names []string) bool { + for _, name := range names { + service := grpcServicePlanByName(services, name) + if service != nil && service.usesAny { + return true + } + } + return false +} + +func grpcResponseMetadataUsesAny(service *expr.GRPCServiceExpr) bool { + for _, endpoint := range service.GRPCEndpoints { + for _, metadata := range []*expr.MappedAttributeExpr{endpoint.Response.Headers, endpoint.Response.Trailers} { + if metadata == nil { + continue + } + for _, named := range *expr.AsObject(metadata.Type) { + typeKind := named.Attribute.Type.Kind() + if array := expr.AsArray(named.Attribute.Type); array != nil { + typeKind = array.ElemType.Type.Kind() + } + if typeKind == expr.AnyKind { + return true + } + } + } + } + return false +} + +func grpcServicePlanByName(services []*grpcServicePlan, name string) *grpcServicePlan { + for _, service := range services { + if service.expression.Name() == name { + return service + } + } + return nil +} diff --git a/grpc/codegen/metadata_specialization_test.go b/grpc/codegen/metadata_specialization_test.go new file mode 100644 index 0000000000..d019a82b01 --- /dev/null +++ b/grpc/codegen/metadata_specialization_test.go @@ -0,0 +1,107 @@ +// This file verifies that gRPC metadata uses the exact string conversion for +// each primitive type selected by the design. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +func TestMetadataEncodingSpecializesPrimitiveFormatting(t *testing.T) { + root := expr.RunDSL(t, func() { + alias := dsl.Type("Count", dsl.Int) + fields := dsl.Type("Fields", func() { + dsl.Field(1, "boolean", dsl.Boolean) + dsl.Field(2, "integer", dsl.Int) + dsl.Field(3, "small", dsl.Int32) + dsl.Field(4, "large", dsl.Int64) + dsl.Field(5, "unsigned", dsl.UInt) + dsl.Field(6, "unsigned_small", dsl.UInt32) + dsl.Field(7, "unsigned_large", dsl.UInt64) + dsl.Field(8, "ratio", dsl.Float32) + dsl.Field(9, "score", dsl.Float64) + dsl.Field(10, "text", dsl.String) + dsl.Field(11, "bytes", dsl.Bytes) + dsl.Field(12, "count", alias) + dsl.Field(13, "booleans", dsl.ArrayOf(dsl.Boolean)) + dsl.Field(14, "dynamic", dsl.Any) + dsl.Field(15, "dynamic_values", dsl.ArrayOf(dsl.Any)) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(fields) + dsl.Result(fields) + dsl.GRPC(func() { + dsl.Metadata(func() { + metadataFields() + }) + dsl.Response(func() { + dsl.Headers(func() { + metadataFields() + }) + }) + }) + }) + }) + }) + + services := CreateGRPCServices(root) + generatedClientFiles := clientFiles(services) + generatedServerFiles := serverFiles(services) + request := codegen.SectionsCode(t, generatedClientFiles[1].Section("request-encoder")) + response := codegen.SectionsCode(t, generatedServerFiles[1].Section("response-encoder")) + for _, generated := range []string{request, response} { + require.Contains(t, generated, "strconv.FormatBool(booleanWire)") + require.Contains(t, generated, "strconv.Itoa(integerWire)") + require.Contains(t, generated, "strconv.FormatInt(int64(smallWire), 10)") + require.Contains(t, generated, "strconv.FormatInt(largeWire, 10)") + require.Contains(t, generated, "strconv.FormatUint(uint64(unsignedWire), 10)") + require.Contains(t, generated, "strconv.FormatUint(uint64(unsignedSmallWire), 10)") + require.Contains(t, generated, "strconv.FormatUint(unsignedLargeWire, 10)") + require.Contains(t, generated, "strconv.FormatFloat(float64(ratioWire), 'f', -1, 32)") + require.Contains(t, generated, "strconv.FormatFloat(scoreWire, 'f', -1, 64)") + require.Contains(t, generated, `Append("text", textWire)`) + require.Contains(t, generated, `Append("bytes", string(bytesWire))`) + require.Contains(t, generated, "strconv.Itoa(countWire)") + require.Contains(t, generated, "strconv.FormatBool(value)") + require.Contains(t, generated, `fmt.Sprintf("%v", dynamicWire)`) + require.Contains(t, generated, `fmt.Sprintf("%v", value)`) + require.NotContains(t, generated, `fmt.Sprintf("%v", integerWire)`) + require.NotContains(t, generated, `fmt.Sprintf("%v", booleanWire)`) + } + require.Contains(t, sectionCode(t, generatedClientFiles[1].SectionTemplates[0]), `"fmt"`) + require.Contains(t, sectionCode(t, generatedServerFiles[1].SectionTemplates[0]), `"fmt"`) + + withoutAny := CreateGRPCServices(RunGRPCDSL(t, testdata.MessageWithMetadataDSL)) + require.NotContains(t, sectionCode(t, clientFiles(withoutAny)[1].SectionTemplates[0]), `"fmt"`) + require.NotContains(t, sectionCode(t, serverFiles(withoutAny)[1].SectionTemplates[0]), `"fmt"`) +} + +// metadataFields maps every test field to a metadata key with the same name. +func metadataFields() { + for _, name := range []string{ + "boolean", + "integer", + "small", + "large", + "unsigned", + "unsigned_small", + "unsigned_large", + "ratio", + "score", + "text", + "bytes", + "count", + "booleans", + "dynamic", + "dynamic_values", + } { + dsl.Attribute(name) + } +} diff --git a/grpc/codegen/oneof_anonymous_user_union_test.go b/grpc/codegen/oneof_anonymous_user_union_test.go index 04b7e7bbb2..151b9562af 100644 --- a/grpc/codegen/oneof_anonymous_user_union_test.go +++ b/grpc/codegen/oneof_anonymous_user_union_test.go @@ -1,3 +1,5 @@ +// This file verifies protobuf generation for unions containing anonymous user +// type branches. package codegen import ( @@ -44,12 +46,17 @@ func TestAnonymousUserUnionArrayNoWrappersFromProto(t *testing.T) { }) sd := &ServiceData{Name: "Svc", Scope: codegen.NewNameScope()} - svcCtx := serviceTypeContext("proto", sd.Scope) - pbCtx := protoBufTypeContext("proto", sd.Scope, true) + svcCtx := codegen.NewAttributeContext(false, false, true, "proto", sd.Scope) // Transform protobuf -> Go for Container target := &expr.AttributeExpr{Type: root.UserType("Container")} - source := makeProtoBufMessage(expr.DupAtt(target), target.Type.Name(), sd) + source := makeProtoBufMessage( + expr.DupAtt(target), + target.Type.Name(), + testGRPCMessageExampleIdentity("anonymous-user-union"), + ) + freezeProtoBufTransformMessages(t, sd, source) + pbCtx := protoBufTypeContext("proto", sd, true) code, _, err := protoBufTransform(source, target, "source", "target", pbCtx, svcCtx, false, true) require.NoError(t, err) diff --git a/grpc/codegen/parse_endpoint_test.go b/grpc/codegen/parse_endpoint_test.go index ca592dcaa9..5d2d85094b 100644 --- a/grpc/codegen/parse_endpoint_test.go +++ b/grpc/codegen/parse_endpoint_test.go @@ -1,3 +1,5 @@ +// This file verifies that gRPC client endpoint parsing renders from a legal +// generated package root while preserving configured interceptor wiring. package codegen import ( @@ -25,8 +27,8 @@ func TestParseEndpointWithInterceptors(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) - services := CreateGRPCServices(root) - fs := ClientCLIFiles("", services) + services := createServiceServicesForPackage(root, "generated.local/gen") + fs := clientCLIFiles(services) require.Greater(t, len(fs), 1, "expected at least 2 files") require.NotEmpty(t, fs[0].SectionTemplates) var buf bytes.Buffer diff --git a/grpc/codegen/plan.go b/grpc/codegen/plan.go new file mode 100644 index 0000000000..a3fd71dcec --- /dev/null +++ b/grpc/codegen/plan.go @@ -0,0 +1,369 @@ +// This file stores one gRPC design, its chosen Go names, and every file built +// from it. +package codegen + +import ( + "fmt" + "path" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // PlanInput pairs one design with the generated service names chosen for it. + PlanInput struct { + // Root is the design that contains the gRPC services. + Root *expr.RootExpr + // Service provides the method names selected for Root. + Service *service.Plan + } + + // Plan stores one design and every gRPC file built from it. + Plan struct { + generation *codegen.Generation + root *expr.RootExpr + service *service.Plan + cli *grpcCLIPlan + protobuf map[*expr.GRPCServiceExpr]*protobufServicePlan + packages map[*expr.GRPCServiceExpr]*grpcServicePackage + tools map[*expr.GRPCServiceExpr]*protobufToolPlan + symbols map[*expr.GRPCServiceExpr]*grpcSymbols + expressions []*expr.GRPCServiceExpr + servicesPlan []*grpcServicePlan + services *ServicesData + proto []*codegen.File + server []*codegen.File + client []*codegen.File + serverType []*codegen.File + clientType []*codegen.File + clientCLI []*codegen.File + } + + // ExamplePlan builds runnable gRPC programs from server data and generated + // services that came from the same design. + ExamplePlan struct { + root *example.Root + transport *Plan + } + + // grpcCLIPlan contains the command parser and payload function names for one + // design. + grpcCLIPlan struct { + parsers map[*expr.ServerExpr]*cli.ParserPlan + builders map[*expr.GRPCEndpointExpr]*codegen.NameDeclaration + servers []*grpcCLIServerPlan + } + + // grpcCLIServerPlan stores one server name and the command parser declared + // for that server before generated files are built. + grpcCLIServerPlan struct { + expression *expr.ServerExpr + name string + parser *cli.ParserPlan + } + + // grpcServicePackage stores the generated service import and the directory + // used by every gRPC package for that service. + grpcServicePackage struct { + service *codegen.ImportSpec + views *codegen.ImportSpec + pathName string + } +) + +// NewPlans reads every service design and stores one plan for each input. It +// chooses all shared package names before files are built. +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + return newPlans(generation, systemProtobufTools(), inputs...) +} + +// NewExamplePlan returns an example renderer only when examples contains the +// server data copied from transport's service design. +func NewExamplePlan(transport *Plan, examples *example.Plan) (*ExamplePlan, error) { + root, ok := examples.Root(transport.service) + if !ok { + return nil, fmt.Errorf("gRPC examples require server data created from the same service design") + } + if err := planGRPCExampleImports(transport.generation, transport, root); err != nil { + return nil, err + } + return &ExamplePlan{root: root, transport: transport}, nil +} + +// newPlans lets tests provide fixed protobuf executable paths and versions. +func newPlans(generation *codegen.Generation, resolver protobufToolResolver, inputs ...PlanInput) ([]*Plan, error) { + owned := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + if root, ok := candidate.(*expr.RootExpr); ok && len(root.API.GRPC.Services) > 0 { + owned[root] = struct{}{} + } + } + if len(inputs) != len(owned) { + return nil, fmt.Errorf("gRPC planning requires all %d gRPC roots, got %d", len(owned), len(inputs)) + } + seen := make(map[*expr.RootExpr]struct{}, len(inputs)) + for _, input := range inputs { + if input.Service == nil || input.Service.Root() != input.Root { + return nil, fmt.Errorf("gRPC plan input does not pair a design with its service plan") + } + if _, ok := owned[input.Root]; !ok { + return nil, fmt.Errorf("gRPC root %p is not part of this generation", input.Root) + } + if _, ok := seen[input.Root]; ok { + return nil, fmt.Errorf("gRPC root %p is planned more than once", input.Root) + } + seen[input.Root] = struct{}{} + } + toolPlans, err := planProtobufTools(inputs, resolver) + if err != nil { + return nil, err + } + plans := make([]*Plan, len(inputs)) + for index, input := range inputs { + packages, err := planGRPCServicePackages(input) + if err != nil { + return nil, err + } + cliPlan, err := planGRPCCLI(generation, input, packages) + if err != nil { + return nil, err + } + tools := make(map[*expr.GRPCServiceExpr]*protobufToolPlan, len(input.Root.API.GRPC.Services)) + for _, grpcService := range input.Root.API.GRPC.Services { + tools[grpcService] = toolPlans[grpcService] + } + plans[index] = &Plan{ + generation: generation, + root: input.Root, + service: input.Service, + cli: cliPlan, + protobuf: make(map[*expr.GRPCServiceExpr]*protobufServicePlan), + packages: packages, + tools: tools, + symbols: make(map[*expr.GRPCServiceExpr]*grpcSymbols), + expressions: append([]*expr.GRPCServiceExpr(nil), input.Root.API.GRPC.Services...), + } + } + if err := planProtobufServices(generation, plans); err != nil { + return nil, err + } + conversions := make(map[grpcConversionKey]*grpcConversion) + var helpers []*grpcTransform + for _, plan := range plans { + input := PlanInput{Root: plan.root, Service: plan.service} + for _, grpcService := range plan.expressions { + pathName := plan.packages[grpcService].pathName + symbols, err := collectGRPCSymbols(generation, input, grpcService, pathName) + if err != nil { + return nil, err + } + if err := planGRPCValidations(generation, input, grpcService, plan.protobuf[grpcService], pathName); err != nil { + return nil, err + } + if err := planGRPCTransforms(generation, input, grpcService, plan.protobuf[grpcService], symbols, conversions, &helpers, pathName); err != nil { + return nil, err + } + plan.symbols[grpcService] = symbols + } + } + if err := declareGRPCTransforms(conversions, helpers); err != nil { + return nil, err + } + for _, plan := range plans { + servicesPlan, err := collectGRPCServicePlans(generation, plan) + if err != nil { + return nil, err + } + plan.servicesPlan = servicesPlan + if err := planGRPCImports(generation, plan); err != nil { + return nil, err + } + } + return plans, nil +} + +// Generation returns the generation that owns this plan's package names. +func (p *Plan) Generation() *codegen.Generation { + return p.generation +} + +// Root returns the exact design supplied to NewPlans. +func (p *Plan) Root() *expr.RootExpr { + return p.root +} + +// Service returns the exact service plan supplied to NewPlans. +func (p *Plan) Service() *service.Plan { + return p.service +} + +// ServiceData returns the finalized gRPC data for the exact service used to +// build this plan. Callers must call Link before reading the service data. +func (p *Plan) ServiceData(service *expr.GRPCServiceExpr) (*ServiceData, bool) { + p.requireLinked() + data, ok := p.services.serviceByExpr[service] + return data, ok +} + +// Link builds the gRPC files after all generated Go names are fixed. The +// service plan must already have built its files. +func (p *Plan) Link() error { + if !p.generation.Frozen() { + return fmt.Errorf("gRPC plan cannot link before generation freeze") + } + if p.services != nil { + return fmt.Errorf("gRPC plan is already linked") + } + services := newServicesData(p.service.Services(), p) + p.services = services + p.proto = protoFiles(services) + p.server = serverFiles(services) + p.client = clientFiles(services) + p.serverType = serverTypeFiles(services) + p.clientType = clientTypeFiles(services) + p.clientCLI = clientCLIFiles(services) + return nil +} + +// ProtoFiles returns the protobuf schemas built by Link. +func (p *Plan) ProtoFiles() []*codegen.File { + p.requireLinked() + return p.proto +} + +// ServerFiles returns the gRPC server files built by Link. +func (p *Plan) ServerFiles() []*codegen.File { + p.requireLinked() + return p.server +} + +// ClientFiles returns the gRPC client files built by Link. +func (p *Plan) ClientFiles() []*codegen.File { + p.requireLinked() + return p.client +} + +// ServerTypeFiles returns the server transport type files built by Link. +func (p *Plan) ServerTypeFiles() []*codegen.File { + p.requireLinked() + return p.serverType +} + +// ClientTypeFiles returns the client transport type files built by Link. +func (p *Plan) ClientTypeFiles() []*codegen.File { + p.requireLinked() + return p.clientType +} + +// ClientCLIFiles returns the command-line client files built by Link. +func (p *Plan) ClientCLIFiles() []*codegen.File { + p.requireLinked() + return p.clientCLI +} + +// ServerFiles builds runnable gRPC servers from the copied server data. +func (p *ExamplePlan) ServerFiles() []*codegen.File { + p.transport.requireLinked() + return exampleServerFiles(p.root, p.transport.services) +} + +// CLIFiles builds runnable gRPC clients from the copied server data. +func (p *ExamplePlan) CLIFiles() []*codegen.File { + p.transport.requireLinked() + return exampleCLIFiles(p.root, p.transport.services) +} + +// planGRPCCLI chooses parser and payload builder names for one design. +func planGRPCCLI(generation *codegen.Generation, input PlanInput, packages map[*expr.GRPCServiceExpr]*grpcServicePackage) (*grpcCLIPlan, error) { + design := input.Root + plan := &grpcCLIPlan{ + parsers: make(map[*expr.ServerExpr]*cli.ParserPlan), + builders: make(map[*expr.GRPCEndpointExpr]*codegen.NameDeclaration), + } + for _, grpcService := range design.API.GRPC.Services { + pathName := packages[grpcService].pathName + clientPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + if err != nil { + return nil, err + } + for _, endpoint := range grpcService.GRPCEndpoints { + if endpoint.MethodExpr.Payload.Type == expr.Empty { + continue + } + names, err := input.Service.HTTPMethodNames(endpoint.MethodExpr) + if err != nil { + return nil, err + } + declaration, err := cli.DeclarePayloadBuilder(clientPackage, "grpc", design.API.Name, grpcService.Name(), endpoint.Name(), "Build"+names.Method+"Payload") + if err != nil { + return nil, err + } + plan.builders[endpoint] = declaration + } + } + if len(design.API.GRPC.Services) == 0 { + return plan, nil + } + for _, server := range design.API.Servers { + serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) + serverPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", "cli", serverName)) + if err != nil { + return nil, err + } + var commands []cli.CommandDeclarationInput + for _, serviceName := range server.Services { + grpcService := design.API.GRPC.Service(serviceName) + if grpcService == nil { + continue + } + if len(grpcService.GRPCEndpoints) == 0 { + continue + } + command := cli.CommandDeclarationInput{Service: grpcService.Name()} + for _, endpoint := range grpcService.GRPCEndpoints { + command.Methods = append(command.Methods, endpoint.Name()) + } + commands = append(commands, command) + } + parser, err := cli.DeclareParser(serverPackage, "grpc", design.API.Name, server.Name, commands) + if err != nil { + return nil, err + } + plan.parsers[server] = parser + plan.servers = append(plan.servers, &grpcCLIServerPlan{ + expression: server, + name: server.Name, + parser: parser, + }) + } + return plan, nil +} + +// planGRPCServicePackages records the exact service imports before generated +// package names become final. Every later gRPC planning step uses these paths. +func planGRPCServicePackages(input PlanInput) (map[*expr.GRPCServiceExpr]*grpcServicePackage, error) { + packages := make(map[*expr.GRPCServiceExpr]*grpcServicePackage, len(input.Root.API.GRPC.Services)) + for _, transportService := range input.Root.API.GRPC.Services { + serviceImport, viewsImport, err := input.Service.ServicePackageImports(transportService.ServiceExpr) + if err != nil { + return nil, err + } + packages[transportService] = &grpcServicePackage{ + service: serviceImport, + views: viewsImport, + pathName: path.Base(serviceImport.Path), + } + } + return packages, nil +} + +// requireLinked stops file reads before Link stores the files. +func (p *Plan) requireLinked() { + if p.services == nil { + panic("gRPC files requested before plan linking") + } +} diff --git a/grpc/codegen/plan_retention_test.go b/grpc/codegen/plan_retention_test.go new file mode 100644 index 0000000000..82d31b9880 --- /dev/null +++ b/grpc/codegen/plan_retention_test.go @@ -0,0 +1,210 @@ +// This file proves gRPC files use the service and endpoint values saved by +// NewPlans even when a caller changes the evaluated design before Link. +package codegen + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +type grpcPlanRetentionFixture struct { + root *expr.RootExpr + service *expr.GRPCServiceExpr + endpoint *expr.GRPCEndpointExpr + method *expr.MethodExpr +} + +// TestGRPCPlanIgnoresDesignChangesAfterPlanning checks endpoint membership, +// messages, metadata, validation, and streaming decisions separately. +func TestGRPCPlanIgnoresDesignChangesAfterPlanning(t *testing.T) { + baselineFixture := grpcPlanRetentionDSL(t) + baseline := renderGRPCPlanRetentionFixture(t, baselineFixture, nil) + + tests := []struct { + name string + mutate func(*grpcPlanRetentionFixture) + }{ + {"service membership", func(f *grpcPlanRetentionFixture) { + f.root.API.GRPC.Services = nil + }}, + {"server membership", func(f *grpcPlanRetentionFixture) { + f.root.API.Servers = nil + }}, + {"endpoint membership", func(f *grpcPlanRetentionFixture) { + f.service.GRPCEndpoints = append(f.service.GRPCEndpoints, &expr.GRPCEndpointExpr{}) + }}, + {"request messages", func(f *grpcPlanRetentionFixture) { + f.endpoint.Request.Type = expr.Empty + f.endpoint.StreamingRequest.Type = expr.Empty + }}, + {"response message and metadata", func(f *grpcPlanRetentionFixture) { + f.endpoint.Response.Message.Type = expr.Empty + f.endpoint.Response.Headers = expr.NewEmptyMappedAttributeExpr() + f.endpoint.Response.Trailers = expr.NewEmptyMappedAttributeExpr() + f.endpoint.Response.StatusCode = 13 + }}, + {"request metadata", func(f *grpcPlanRetentionFixture) { + f.endpoint.Metadata = expr.NewEmptyMappedAttributeExpr() + }}, + {"validation", func(f *grpcPlanRetentionFixture) { + f.endpoint.Request.Validation.Required = nil + field := expr.AsObject(f.method.Payload.Type).Attribute("value") + field.Validation.MinLength = nil + }}, + {"imports", func(f *grpcPlanRetentionFixture) { + field := expr.AsObject(f.method.Payload.Type).Attribute("value") + field.Meta["struct:field:type"] = []string{"time.Time", "time"} + }}, + {"streaming method", func(f *grpcPlanRetentionFixture) { + f.method.Stream = expr.NoStreamKind + f.method.StreamingPayload.Type = expr.Empty + f.method.StreamingResult.Type = expr.Empty + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := grpcPlanRetentionDSL(t) + actual := renderGRPCPlanRetentionFixture(t, fixture, test.mutate) + require.Equal(t, baseline, actual) + }) + } +} + +// TestGRPCPlanCopiesMissingStreamingResult checks that a unary method keeps +// its allowed nil streaming result when NewPlans copies the method. +func TestGRPCPlanCopiesMissingStreamingResult(t *testing.T) { + root := grpcPlanRoots(t, "Unary")[0] + require.Nil(t, root.API.GRPC.Services[0].GRPCEndpoints[0].MethodExpr.StreamingResult) + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: root, Service: services[0]}, + ) + require.NoError(t, err) + require.Nil(t, plans[0].servicesPlan[0].expression.GRPCEndpoints[0].MethodExpr.StreamingResult) +} + +// TestGRPCPlanCopiesMethodErrors checks that each copied gRPC error uses the +// matching copied method error, including Goa's built-in error value. +func TestGRPCPlanCopiesMethodErrors(t *testing.T) { + root := RunGRPCDSL(t, testdata.UnaryRPCWithErrorsDSL) + service, err := copyGRPCService(root.API.GRPC.Services[0]) + require.NoError(t, err) + endpoint := service.expression.GRPCEndpoints[0] + for _, grpcError := range endpoint.GRPCErrors { + require.Same(t, endpoint.MethodExpr.Error(grpcError.Name), grpcError.ErrorExpr) + } + require.Same(t, expr.ErrorResult, endpoint.MethodExpr.Error("timeout").Type) +} + +// TestGRPCPlanKeepsEmptyPayloadResponseConversion checks that a method without +// a payload still saves and renders its result conversion. +func TestGRPCPlanKeepsEmptyPayloadResponseConversion(t *testing.T) { + baseline := renderGRPCPlanRetentionFixture(t, grpcEmptyPayloadFixture(t), nil) + actual := renderGRPCPlanRetentionFixture(t, grpcEmptyPayloadFixture(t), func(f *grpcPlanRetentionFixture) { + f.method.Result.Type = expr.Empty + f.endpoint.Response.Message.Type = expr.Empty + }) + require.Equal(t, baseline, actual) +} + +// grpcPlanRetentionDSL creates one streaming endpoint with request and +// response metadata and message validation. +func grpcPlanRetentionDSL(t *testing.T) *grpcPlanRetentionFixture { + t.Helper() + fixture := new(grpcPlanRetentionFixture) + fixture.root = expr.RunDSL(t, func() { + payload := dsl.Type("SavedPayload", func() { + dsl.Field(1, "value", dsl.String, func() { dsl.MinLength(2) }) + dsl.Field(2, "token", dsl.String) + dsl.Required("value", "token") + }) + result := dsl.Type("SavedResult", func() { + dsl.Field(1, "value", dsl.String) + dsl.Field(2, "count", dsl.Int) + dsl.Required("value", "count") + }) + stream := dsl.Type("SavedStream", func() { + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + }) + dsl.Service("SavedTransport", func() { + fixture.method = dsl.Method("Watch", func() { + dsl.Payload(payload) + dsl.StreamingPayload(stream) + dsl.StreamingResult(result) + dsl.GRPC(func() { + dsl.Metadata(func() { dsl.Attribute("token:authorization") }) + dsl.Response(dsl.CodeOK, func() { + dsl.Headers(func() { dsl.Attribute("count:x-count") }) + dsl.Trailers(func() { dsl.Attribute("value:x-value") }) + }) + }) + }) + }) + }) + fixture.service = fixture.root.API.GRPC.Services[0] + fixture.endpoint = fixture.service.GRPCEndpoints[0] + return fixture +} + +// grpcEmptyPayloadFixture creates one unary method with no payload and a +// custom result that needs a protobuf conversion. +func grpcEmptyPayloadFixture(t *testing.T) *grpcPlanRetentionFixture { + t.Helper() + fixture := new(grpcPlanRetentionFixture) + fixture.root = expr.RunDSL(t, func() { + result := dsl.Type("SavedEmptyPayloadResult", func() { + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + }) + dsl.Service("SavedEmptyPayload", func() { + fixture.method = dsl.Method("Read", func() { + dsl.Result(result) + dsl.GRPC(func() {}) + }) + }) + }) + fixture.service = fixture.root.API.GRPC.Services[0] + fixture.endpoint = fixture.service.GRPCEndpoints[0] + return fixture +} + +// renderGRPCPlanRetentionFixture saves the design, applies one later change, +// and renders every non-example gRPC file. +func renderGRPCPlanRetentionFixture(t *testing.T, fixture *grpcPlanRetentionFixture, mutate func(*grpcPlanRetentionFixture)) []string { + t.Helper() + generation, services := grpcServicePlans(t, []*expr.RootExpr{fixture.root}) + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: fixture.root, Service: services[0]}, + ) + require.NoError(t, err) + if mutate != nil { + mutate(fixture) + } + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + files := plans[0].ProtoFiles() + files = append(files, plans[0].ServerFiles()...) + files = append(files, plans[0].ClientFiles()...) + files = append(files, plans[0].ServerTypeFiles()...) + files = append(files, plans[0].ClientTypeFiles()...) + files = append(files, plans[0].ClientCLIFiles()...) + result := make([]string, len(files)) + for index, file := range files { + result[index] = file.Path + "\n" + sectionCode(t, file.SectionTemplates...) + } + sort.Strings(result) + return result +} diff --git a/grpc/codegen/plan_service_data_test.go b/grpc/codegen/plan_service_data_test.go new file mode 100644 index 0000000000..51c2b84b39 --- /dev/null +++ b/grpc/codegen/plan_service_data_test.go @@ -0,0 +1,70 @@ +// This file checks that retained gRPC plans link the exact service expressions +// and do not invent protobuf messages for payloads built only from metadata. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" +) + +func TestPlanServiceDataUsesExactExpressionAfterLink(t *testing.T) { + roots := grpcPlanRoots(t, "Calc") + generation, services := grpcServicePlans(t, roots) + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: roots[0], Service: services[0]}, + ) + require.NoError(t, err) + require.PanicsWithValue(t, "gRPC files requested before plan linking", func() { + plans[0].ServiceData(roots[0].API.GRPC.Services[0]) + }) + + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + data, ok := plans[0].ServiceData(roots[0].API.GRPC.Services[0]) + require.True(t, ok) + require.Equal(t, "Calc", data.Name) + require.NotEmpty(t, data.ClientStruct) + require.NotEmpty(t, data.ServerStruct) + + foreign := grpcPlanRoots(t, "Calc") + data, ok = plans[0].ServiceData(foreign[0].API.GRPC.Services[0]) + require.False(t, ok) + require.Nil(t, data) +} + +// TestPlanLinksMetadataOnlyStreamingPayload verifies that a payload built only +// from metadata does not require a protobuf request message. +func TestPlanLinksMetadataOnlyStreamingPayload(t *testing.T) { + root := RunGRPCDSL(t, func() { + dsl.Service("Chatter", func() { + dsl.Method("Echo", func() { + dsl.Payload(func() { + dsl.Field(1, "token", dsl.String) + dsl.Required("token") + }) + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.GRPC(func() { + dsl.Metadata(func() { + dsl.Attribute("token") + }) + }) + }) + }) + }) + + services := CreateGRPCServices(root) + request := services.Get("Chatter").Endpoints[0].Request + require.Nil(t, request.PayloadMessage) + require.NotNil(t, request.ServerConvert) + require.Empty(t, request.ServerConvert.SrcName) + require.Empty(t, request.ServerConvert.SrcRef) + require.Nil(t, request.ServerConvert.Validation) +} diff --git a/grpc/codegen/plan_test.go b/grpc/codegen/plan_test.go new file mode 100644 index 0000000000..7e0d2bb160 --- /dev/null +++ b/grpc/codegen/plan_test.go @@ -0,0 +1,308 @@ +// This file verifies one gRPC plan keeps the exact design, service plan, and +// generated files selected for a generation run. +package codegen + +import ( + "fmt" + "path" + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestNewPlansKeepsExactInputs checks that each result keeps its input pair. +func TestNewPlansKeepsExactInputs(t *testing.T) { + roots := grpcPlanRoots(t, "First", "Second") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, + PlanInput{Root: roots[1], Service: services[1]}, + PlanInput{Root: roots[0], Service: services[0]}, + ) + require.NoError(t, err) + require.Same(t, generation, plans[0].Generation()) + require.Same(t, roots[1], plans[0].Root()) + require.Same(t, services[1], plans[0].Service()) + require.Same(t, roots[0], plans[1].Root()) + require.Same(t, services[0], plans[1].Service()) +} + +// TestNewExamplePlanRejectsAnotherServicePlan checks that server names and +// URLs cannot come from a different design with the same authored names. +func TestNewExamplePlanRejectsAnotherServicePlan(t *testing.T) { + roots := grpcPlanRoots(t, "Service") + generation, services := grpcServicePlans(t, roots) + plans, err := newPlans(generation, fixedProtobufToolResolver(), PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + + otherRoots := grpcPlanRoots(t, "Service") + otherGeneration, otherServices := grpcServicePlans(t, otherRoots) + examples, err := example.NewPlan(otherGeneration, otherServices[0]) + require.NoError(t, err) + + _, err = NewExamplePlan(plans[0], examples) + require.EqualError(t, err, "gRPC examples require server data created from the same service design") +} + +// TestNewPlansRequiresEveryRoot checks that a batch cannot omit a design. +func TestNewPlansRequiresEveryRoot(t *testing.T) { + roots := grpcPlanRoots(t, "First", "Second") + generation, services := grpcServicePlans(t, roots) + _, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.EqualError(t, err, "gRPC planning requires all 2 gRPC roots, got 1") +} + +// TestNewPlansRejectsDuplicateRoot checks that a batch cannot repeat a design. +func TestNewPlansRejectsDuplicateRoot(t *testing.T) { + roots := grpcPlanRoots(t, "First", "Second") + generation, services := grpcServicePlans(t, roots) + _, err := NewPlans(generation, + PlanInput{Root: roots[0], Service: services[0]}, + PlanInput{Root: roots[0], Service: services[0]}, + ) + require.EqualError(t, err, fmt.Sprintf("gRPC root %p is planned more than once", roots[0])) +} + +// TestNewPlansRejectsMismatchedServicePlan checks that input pairs must match. +func TestNewPlansRejectsMismatchedServicePlan(t *testing.T) { + roots := grpcPlanRoots(t, "First", "Second") + generation, services := grpcServicePlans(t, roots) + _, err := NewPlans(generation, + PlanInput{Root: roots[0], Service: services[1]}, + PlanInput{Root: roots[1], Service: services[0]}, + ) + require.EqualError(t, err, "gRPC plan input does not pair a design with its service plan") +} + +// TestPlanLinksOnceAfterFreeze checks the required link order. +func TestPlanLinksOnceAfterFreeze(t *testing.T) { + roots := grpcPlanRoots(t, "Calc") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.EqualError(t, plans[0].Link(), "gRPC plan cannot link before generation freeze") + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + require.EqualError(t, plans[0].Link(), "gRPC plan is already linked") +} + +// TestPlanLinksStoredServicesAfterRootListRemoved checks that Link builds the +// services selected by NewPlans without reading the root service list again. +func TestPlanLinksStoredServicesAfterRootListRemoved(t *testing.T) { + roots := grpcPlanRoots(t, "Calc") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + + roots[0].API.GRPC.Services = nil + require.NoError(t, plans[0].Link()) + require.NotEmpty(t, plans[0].ProtoFiles()) + require.NotEmpty(t, plans[0].ServerFiles()) + require.NotEmpty(t, plans[0].ClientFiles()) + require.NotEmpty(t, plans[0].ServerTypeFiles()) + require.NotEmpty(t, plans[0].ClientTypeFiles()) +} + +// TestPlanReturnsStoredFiles checks that later reads reuse the linked files. +func TestPlanReturnsStoredFiles(t *testing.T) { + roots := grpcPlanRoots(t, "Calc") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + want := grpcPlanFileSignatures(t, plans[0]) + roots[0].API.GRPC.Services = append(roots[0].API.GRPC.Services, &expr.GRPCServiceExpr{}) + require.Equal(t, want, grpcPlanFileSignatures(t, plans[0])) + require.Equal(t, want, grpcPlanFileSignatures(t, plans[0])) +} + +// TestNewPlansIsIndependentOfInputOrder checks that input order does not +// change files written to the same generated packages. +func TestNewPlansIsIndependentOfInputOrder(t *testing.T) { + forwardNames, forwardErr := collidingGRPCPlanResult(t, false) + reverseNames, reverseErr := collidingGRPCPlanResult(t, true) + require.Empty(t, forwardErr) + require.Empty(t, reverseErr) + require.Equal(t, forwardNames, reverseNames) +} + +// TestGRPCCLIImportAliasesBelongToOutputPackage checks that a name used by an +// unrelated HTTP package cannot change the qualifier written by the gRPC +// command parser package. +func TestGRPCCLIImportAliasesBelongToOutputPackage(t *testing.T) { + roots := grpcPlanRoots(t, "Echo") + generation, services := grpcServicePlans(t, roots) + httpPackage, err := generation.ClaimPackage("generated.local/gen/http/unrelated/client") + require.NoError(t, err) + require.NoError(t, httpPackage.ReserveGeneratedImport(codegen.NewImport("echoc", "example.com/unrelated/client"))) + + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + serverName := codegen.SnakeCase(codegen.Goify(roots[0].API.Servers[0].Name, true)) + cliPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", "cli", serverName)) + clientPath := path.Join(generation.GenPkg(), "grpc", "echo", "client") + require.Equal(t, "echoc", cliPackage.ImportName(clientPath)) +} + +// TestGRPCReferencesUseTheirOutputPackageAlias checks that client and server +// source use their own service import names when only the server package also +// imports the standard strings package. +func TestGRPCReferencesUseTheirOutputPackageAlias(t *testing.T) { + roots := grpcPlanRoots(t, "Strings") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + clientCode := sectionCode(t, plans[0].ClientFiles()[1].SectionTemplates...) + serverCode := sectionCode(t, plans[0].ServerFiles()[0].SectionTemplates...) + require.Contains(t, clientCode, `strings "generated.local/gen/strings"`) + require.Contains(t, clientCode, `*strings.ReadPayload`) + require.Contains(t, serverCode, `strings2 "generated.local/gen/strings"`) + require.Contains(t, serverCode, `*strings2.Endpoints`) +} + +// TestGRPCServerUsesItsOwnProtobufAlias checks that the runtime protobuf +// import used by a client cannot carry its suffixed alias into server source. +func TestGRPCServerUsesItsOwnProtobufAlias(t *testing.T) { + root := expr.RunDSL(t, func() { + for _, serviceName := range []string{"Goa", "Goapb"} { + dsl.Service(serviceName, func() { + dsl.Method("Read", func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + dsl.GRPC(func() {}) + }) + }) + } + }) + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + serverCode := sectionCode(t, plans[0].ServerFiles()[0].SectionTemplates...) + require.Contains(t, serverCode, `goapb "generated.local/gen/grpc/goa/pb"`) + require.NotContains(t, serverCode, "goapb2.") +} + +// grpcPlanRoots creates independent designs with one unary gRPC method. +func grpcPlanRoots(t *testing.T, serviceNames ...string) []*expr.RootExpr { + t.Helper() + roots := make([]*expr.RootExpr, len(serviceNames)) + for index, serviceName := range serviceNames { + roots[index] = expr.RunDSL(t, func() { + dsl.Service(serviceName, func() { + dsl.Method("Read", func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + dsl.Result(func() { dsl.Field(1, "value", dsl.String) }) + dsl.GRPC(func() {}) + }) + }) + }) + } + return roots +} + +// grpcServicePlans creates one generation and the service plan for each root. +func grpcServicePlans(t *testing.T, roots []*expr.RootExpr) (*codegen.Generation, []*service.Plan) { + t.Helper() + evaluated := make([]eval.Root, len(roots)) + inputs := make([]service.PlanInput, len(roots)) + for index, root := range roots { + evaluated[index] = root + inputs[index] = service.PlanInput{ + Root: root, + Examples: expr.NewExampleGenerator(root.API.RandomizerFactory), + } + } + generation, err := codegen.NewGeneration("generated.local/gen", evaluated) + require.NoError(t, err) + plans, err := service.NewPlans(generation, inputs...) + require.NoError(t, err) + return generation, plans +} + +// grpcPlanFileSignatures renders every stored file for a stable comparison. +func grpcPlanFileSignatures(t *testing.T, plan *Plan) []string { + t.Helper() + files := plan.ProtoFiles() + files = append(files, plan.ServerFiles()...) + files = append(files, plan.ClientFiles()...) + files = append(files, plan.ServerTypeFiles()...) + files = append(files, plan.ClientTypeFiles()...) + files = append(files, plan.ClientCLIFiles()...) + signatures := make([]string, len(files)) + for index, file := range files { + signatures[index] = file.Path + "\n" + sectionCode(t, file.SectionTemplates...) + } + sort.Strings(signatures) + return signatures +} + +// collidingGRPCPlanResult returns stored files or the exact planning error for +// two designs that write the same gRPC and protobuf packages. +func collidingGRPCPlanResult(t *testing.T, reverse bool) ([]string, string) { + t.Helper() + makeRoot := func(serviceName, typeName string) *expr.RootExpr { + return expr.RunDSL(t, func() { + choice := dsl.Type(typeName, func() { + dsl.Meta("struct:name:proto", "API2_Choice") + dsl.OneOf("state", func() { + dsl.Field(1, "api2URL", dsl.String) + dsl.Field(2, "reset", dsl.String) + }) + }) + dsl.Service(serviceName, func() { + dsl.Method("Sync2URL", func() { + dsl.Payload(choice) + dsl.StreamingPayload(choice) + dsl.StreamingResult(choice) + dsl.GRPC(func() {}) + }) + }) + }) + } + roots := []*expr.RootExpr{makeRoot("Foo Bar", "FirstChoice"), makeRoot("Foo-Bar", "SecondChoice")} + generation, services := grpcServicePlans(t, roots) + inputs := []PlanInput{{Root: roots[0], Service: services[0]}, {Root: roots[1], Service: services[1]}} + if reverse { + inputs[0], inputs[1] = inputs[1], inputs[0] + } + plans, err := NewPlans(generation, inputs...) + if err != nil { + return nil, err.Error() + } + require.NoError(t, generation.Freeze()) + for _, servicePlan := range services { + require.NoError(t, servicePlan.Link()) + } + var names []string + for _, plan := range plans { + if err := plan.Link(); err != nil { + return nil, err.Error() + } + names = append(names, grpcPlanFileSignatures(t, plan)...) + } + sort.Strings(names) + return names, "" +} diff --git a/grpc/codegen/planned_name_collision_test.go b/grpc/codegen/planned_name_collision_test.go new file mode 100644 index 0000000000..15edeaad38 --- /dev/null +++ b/grpc/codegen/planned_name_collision_test.go @@ -0,0 +1,133 @@ +// This file checks that gRPC definitions and their callers use the package +// names selected after preferred names are already taken. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/expr" +) + +// TestGRPCPlannedNamesSurvivePackageCollisions covers endpoint functions, +// stream types, conversion constructors, and validators in both definitions +// and calls. +func TestGRPCPlannedNamesSurvivePackageCollisions(t *testing.T) { + fixture := grpcPlanRetentionDSL(t) + generation, services := grpcServicePlans(t, []*expr.RootExpr{fixture.root}) + clientPackage, err := generation.ClaimPackage("generated.local/gen/grpc/saved_transport/client") + require.NoError(t, err) + serverPackage, err := generation.ClaimPackage("generated.local/gen/grpc/saved_transport/server") + require.NoError(t, err) + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameFunction, "BuildWatchFunc"), + codegen.NewExactName(codegen.NameFunction, "EncodeWatchRequest"), + codegen.NewExactName(codegen.NameFunction, "DecodeWatchResponse"), + codegen.NewExactName(codegen.NameFunction, "NewProtoWatchRequest"), + codegen.NewExactName(codegen.NameType, "WatchClientStream"), + } { + require.NoError(t, clientPackage.DeclareName(declaration)) + } + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameFunction, "NewWatchHandler"), + codegen.NewExactName(codegen.NameFunction, "DecodeWatchRequest"), + codegen.NewExactName(codegen.NameFunction, "EncodeWatchResponse"), + codegen.NewExactName(codegen.NameFunction, "ValidateWatchRequest"), + codegen.NewExactName(codegen.NameType, "WatchServerStream"), + } { + require.NoError(t, serverPackage.DeclareName(declaration)) + } + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: fixture.root, Service: services[0]}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + data, ok := plans[0].ServiceData(fixture.service) + require.True(t, ok) + endpoint := data.Endpoints[0] + require.NotEqual(t, "BuildWatchFunc", endpoint.ClientBuildDeclaration.Name()) + require.NotEqual(t, "EncodeWatchRequest", endpoint.ClientEncodeDeclaration.Name()) + require.NotEqual(t, "DecodeWatchResponse", endpoint.ClientDecodeDeclaration.Name()) + require.NotEqual(t, "NewProtoWatchRequest", endpoint.Request.ClientConvert.Init.Declaration.Name()) + require.NotEqual(t, "NewWatchHandler", endpoint.ServerHandlerDeclaration.Name()) + require.NotEqual(t, "DecodeWatchRequest", endpoint.ServerDecodeDeclaration.Name()) + require.NotEqual(t, "EncodeWatchResponse", endpoint.ServerEncodeDeclaration.Name()) + require.NotEqual(t, "ValidateWatchRequest", endpoint.Request.ServerConvert.Validation.Declaration.Name()) + require.NotEqual(t, "WatchClientStream", endpoint.ClientStream.Declaration.Name()) + require.NotEqual(t, "WatchServerStream", endpoint.ServerStream.Declaration.Name()) + + var source strings.Builder + for _, selection := range []struct { + files []*codegen.File + section string + }{ + {plans[0].ClientFiles(), "remote-method-builder"}, + {plans[0].ClientFiles(), "request-encoder"}, + {plans[0].ClientFiles(), "response-decoder"}, + {plans[0].ClientFiles(), "client-endpoint-init"}, + {plans[0].ClientFiles(), "client-stream-struct-type"}, + {plans[0].ServerFiles(), "request-decoder"}, + {plans[0].ServerFiles(), "response-encoder"}, + {plans[0].ServerFiles(), "grpc-handler-init"}, + {plans[0].ServerFiles(), "server-grpc-interface"}, + {plans[0].ServerFiles(), "server-stream-struct-type"}, + } { + sections := matchingGRPCSections(selection.files, selection.section, endpoint) + require.NotEmpty(t, sections, "missing %s section", selection.section) + source.WriteString(codegen.SectionsCode(t, sections)) + source.WriteString("\n") + } + for _, selection := range []struct { + files []*codegen.File + section string + }{ + {plans[0].ClientTypeFiles(), "client-type-init"}, + {plans[0].ServerTypeFiles(), "server-type-init"}, + {plans[0].ServerTypeFiles(), "server-validate"}, + } { + sections := namedGRPCSections(selection.files, selection.section) + require.NotEmpty(t, sections, "missing %s section", selection.section) + source.WriteString(codegen.SectionsCode(t, sections)) + source.WriteString("\n") + } + testutil.AssertGo(t, "testdata/golden/planned_name_collisions.go.golden", strings.TrimSpace(source.String())+"\n") +} + +// namedGRPCSections returns every section with name in file order. +func namedGRPCSections(files []*codegen.File, name string) []*codegen.SectionTemplate { + result := make([]*codegen.SectionTemplate, 0, len(files)) + for _, file := range files { + result = append(result, file.Section(name)...) + } + return result +} + +// matchingGRPCSections returns sections for endpoint without depending on file +// order or on the number of other sections in the file. +func matchingGRPCSections(files []*codegen.File, name string, endpoint *EndpointData) []*codegen.SectionTemplate { + var result []*codegen.SectionTemplate + for _, file := range files { + for _, section := range file.Section(name) { + switch data := section.Data.(type) { + case *EndpointData: + if data == endpoint { + result = append(result, section) + } + case *StreamData: + if data.Endpoint == endpoint { + result = append(result, section) + } + } + } + } + return result +} diff --git a/grpc/codegen/proto.go b/grpc/codegen/proto.go index 88e1db6f3a..99763062c6 100644 --- a/grpc/codegen/proto.go +++ b/grpc/codegen/proto.go @@ -1,3 +1,5 @@ +// This file builds protobuf schema files and compiles each one with the tools +// selected before rendering starts. package codegen import ( @@ -5,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "goa.design/goa/v3/codegen" @@ -19,19 +22,27 @@ const ( ProtoPrefix = "goagen" ) -// ProtoFiles returns the protobuf file for every gRPC service. -func ProtoFiles(genpkg string, services *ServicesData) []*codegen.File { - fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = protoFile(genpkg, svc, services) +// defaultProtocCmd selects protoc when the design does not choose a compiler. +var defaultProtocCmd = []string{expr.DefaultProtoc} + +// protoFiles returns the planned protobuf file for every gRPC service. +func protoFiles(services *ServicesData) []*codegen.File { + fw := make([]*codegen.File, len(services.servicePlans)) + for i, servicePlan := range services.servicePlans { + fw[i] = protoFile(servicePlan.expression, services) } return fw } // protoFile returns the protobuf file defining the specified service. -func protoFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func protoFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() data := services.Get(svc.Name()) svcName := data.Service.PathName + fileServiceName := svcName + if planned := services.protobuf[svc]; planned != nil && planned.fileIndex > 1 { + fileServiceName += strconv.Itoa(planned.fileIndex) + } parts := strings.Split(genpkg, "/") var repoName string if len(parts) > 1 { @@ -39,8 +50,9 @@ func protoFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) } else { repoName = parts[0] } - // the filename is used by protoc to set the namespace so try to make it unique - fname := fmt.Sprintf("%s_%s_%s.proto", ProtoPrefix, repoName, svcName) + // Include the repository and service so two services do not write the same + // file. + fname := fmt.Sprintf("%s_%s_%s.proto", ProtoPrefix, repoName, fileServiceName) path := filepath.Join(codegen.Gendir, "grpc", svcName, pbPkgName, fname) sections := make([]*codegen.SectionTemplate, 0, 3+len(data.Messages)) @@ -80,31 +92,22 @@ func protoFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) }) } - runProtoc := func(path string) error { - includes := svc.ServiceExpr.Meta["protoc:include"] - includes = append(includes, services.Root.API.Meta["protoc:include"]...) - - cmd := defaultProtocCmd - if c, ok := services.Root.API.Meta["protoc:cmd"]; ok { - cmd = c - } - if c, ok := svc.ServiceExpr.Meta["protoc:cmd"]; ok { - cmd = c - } - if len(cmd) == 0 { - return fmt.Errorf(`Meta("protoc:cmd"): must be given arguments`) - } - - return protoc(cmd, path, includes) + tools := services.tools[svc] + if tools == nil { + panic(fmt.Sprintf("protobuf tools for service %q were not planned", svc.Name())) } return &codegen.File{ Path: path, SectionTemplates: sections, - FinalizeFunc: runProtoc, + FinalizeFunc: func(path string) error { + return runProtoc(tools, path) + }, } } +// pkgName returns the protobuf package chosen by the design or the service +// name used when the design does not choose one. func pkgName(svc *expr.GRPCServiceExpr, svcName string) string { if svc.ProtoPkg != "" { return svc.ProtoPkg @@ -112,9 +115,8 @@ func pkgName(svc *expr.GRPCServiceExpr, svcName string) string { return codegen.SnakeCase(svcName) } -var defaultProtocCmd = []string{expr.DefaultProtoc} - -func protoc(protocCmd []string, path string, includes []string) error { +// runProtoc compiles one schema with the command fixed during planning. +func runProtoc(tools *protobufToolPlan, path string) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0750); err != nil { return err @@ -127,11 +129,14 @@ func protoc(protocCmd []string, path string, includes []string) error { "--go-grpc_out", dir, "--go_opt=paths=source_relative", "--go-grpc_opt=paths=source_relative", + "--plugin=protoc-gen-go=" + tools.goPlugin, + "--plugin=protoc-gen-go-grpc=" + tools.goGRPCPlugin, } - for _, include := range includes { + for _, include := range tools.includes { args = append(args, "-I", include) } - cmd := exec.Command(protocCmd[0], append(protocCmd[1:len(protocCmd):len(protocCmd)], args...)...) + command := tools.command + cmd := exec.Command(command[0], append(command[1:len(command):len(command)], args...)...) cmd.Dir = filepath.Dir(path) if output, err := cmd.CombinedOutput(); err != nil { diff --git a/grpc/codegen/proto_hooks.go b/grpc/codegen/proto_hooks.go index 82438a96e6..3733474c6e 100644 --- a/grpc/codegen/proto_hooks.go +++ b/grpc/codegen/proto_hooks.go @@ -1,3 +1,5 @@ +// This file tells the shared Go conversion code how protobuf wrappers, +// collections, unions, and nil values differ from service values. package codegen import ( @@ -10,22 +12,27 @@ import ( "goa.design/goa/v3/expr" ) +type ( + // protobufOneofAttributor returns the Go wrapper type generated for one + // protobuf union branch. + protobufOneofAttributor interface { + OneofWrapper(*expr.AttributeExpr) string + } +) + var ( - // renderGoArrayT is the template rendering protocol buffer array - // transformations driven by the shared transform engine. + // renderGoArrayT writes a conversion between service and protobuf arrays. renderGoArrayT *template.Template - // renderGoMapT is the template rendering protocol buffer map - // transformations driven by the shared transform engine. + // renderGoMapT writes a conversion between service and protobuf maps. renderGoMapT *template.Template - // renderGoUnionToProtoT is the template rendering Go union to protobuf - // oneof transformations. + // renderGoUnionToProtoT writes a service union into a protobuf oneof. renderGoUnionToProtoT *template.Template - // renderGoUnionFromProtoT is the template rendering protobuf oneof to - // Go union transformations. + // renderGoUnionFromProtoT writes a protobuf oneof into a service union. renderGoUnionFromProtoT *template.Template ) -// NOTE: can't initialize inline because https://github.com/golang/go/issues/1817 +// The templates are initialized here because Go cannot initialize this cycle +// of template functions in the variable declarations. func init() { fm := template.FuncMap{"transformAttribute": codegen.TransformAttribute} renderGoArrayT = template.Must(template.New("renderGoArray").Funcs(fm).Parse(grpcTemplates.Read(grpcTransformGoArrayT))) @@ -34,24 +41,19 @@ func init() { renderGoUnionFromProtoT = template.Must(template.New("renderGoUnionFromProto").Parse(grpcTemplates.Read(grpcTransformGoUnionFromProtoT))) } -// protoHooks returns the transform hooks that specialize the shared Go -// transform engine for protocol buffer transformations. proto is true when -// the transformation initializes a protocol buffer type from a service type -// and false when it initializes a service type from a protocol buffer type. -// targetCtx is the target attribute context of the transformation; it is used -// to name the synthetic wrapper messages initialized by the generated code. -func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.TransformHooks { +// protoHooks returns the functions that convert between service and protobuf +// values. proto is true when the target is the protobuf value. +func protoHooks(proto bool) *codegen.TransformHooks { return &codegen.TransformHooks{ UnwrapPair: func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *codegen.WrapDirective) { if proto { if isWrappedAttr(tgt) { - name := targetCtx.Scope.Name(tgt, targetCtx.Pkg(tgt), targetCtx.Pointer, targetCtx.UseDefault) - return src, unwrapAttr(expr.DupAtt(tgt)), &codegen.WrapDirective{WrapTarget: true, InitTypeName: name, FieldName: "Field"} + return src, unwrapAttr(tgt), &codegen.WrapDirective{WrapTarget: true, Target: tgt, FieldName: "Field"} } return src, tgt, nil } if isWrappedAttr(src) { - return unwrapAttr(expr.DupAtt(src)), tgt, &codegen.WrapDirective{FieldName: "Field"} + return unwrapAttr(src), tgt, &codegen.WrapDirective{FieldName: "Field"} } return src, tgt, nil }, @@ -61,8 +63,7 @@ func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.Transf ConvertPrimitive: func(src, tgt *expr.AttributeExpr, srcVar string, srcPtr, tgtPtr bool, ta *codegen.TransformAttrs) (string, bool) { exp := convertType(src, tgt, srcPtr, tgtPtr, srcVar, proto, ta) if _, isSrcUT := src.Type.(expr.UserType); isSrcUT && !proto { - // If the source is an alias type and the code is initializing a - // service type then we must cast to the alias type. + // A service alias must keep its named Go type. deref := "" if srcPtr { deref = "*" @@ -77,37 +78,25 @@ func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.Transf TransformMap: func(source, target *expr.Map, sourceVar, targetVar string, newVar bool, ta *codegen.TransformAttrs) (string, error) { return renderMapTransform(source, target, sourceVar, targetVar, newVar, proto, ta) }, - TransformUnion: func(source, target *expr.AttributeExpr, sourceVar, targetVar string, _ bool, srcParent, tgtParent *expr.AttributeExpr, ta *codegen.TransformAttrs) (string, error) { + TransformUnion: func(source, target *expr.AttributeExpr, sourceVar, targetVar string, _ bool, _, _ *expr.AttributeExpr, ta *codegen.TransformAttrs) (string, error) { if proto { - // Go service union fields are interfaces (nil when absent); do - // not dereference. - return renderUnionToProtoTransform(source, target, sourceVar, targetVar, false, oneofMessageName(tgtParent, proto, ta), ta) + // Service union fields are interfaces, so they are not dereferenced. + return renderUnionToProtoTransform(source, target, sourceVar, targetVar, false, ta) } - // Service unions in Goa are represented as interface types, not - // *interface. Always assign concrete values to the interface (no - // pointer-to-interface). - return renderUnionFromProtoTransform(source, target, sourceVar, targetVar, oneofMessageName(srcParent, proto, ta), ta) + // Store the selected value directly in the service union interface. + return renderUnionFromProtoTransform(source, target, sourceVar, targetVar, ta) }, - HelperNameAttrs: func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { - // Do not consider package overrides for protogen generated types. - if proto { - tgt = expr.DupAtt(tgt) - codegen.Walk(tgt, func(att *expr.AttributeExpr) error { // nolint: errcheck - delete(att.Meta, "struct:pkg:path") - return nil - }) - } else { - src = expr.DupAtt(src) - codegen.Walk(src, func(att *expr.AttributeExpr) error { // nolint: errcheck - delete(att.Meta, "struct:pkg:path") - return nil - }) + PlanUnionHelpers: func(source, target *expr.AttributeExpr, record func(*expr.AttributeExpr, *expr.AttributeExpr)) { + sourceUnion, targetUnion := expr.AsUnion(source.Type), expr.AsUnion(target.Type) + for index, sourceBranch := range sourceUnion.Values { + targetBranch := targetUnion.Values[index] + if protoUnionBranchUsesHelper(sourceBranch.Attribute, targetBranch.Attribute) { + record(sourceBranch.Attribute, targetBranch.Attribute) + } } - return src, tgt }, GuardCondition: func(src *expr.AttributeExpr, srcVar string, _, srcPtr bool) (string, bool) { - // Non-primitives are always guarded (proto3 message fields are - // always nilable). + // Protobuf message fields can be nil, so check them before use. if expr.IsPrimitive(src.Type) && !srcPtr { return "", true } @@ -126,7 +115,7 @@ func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.Transf return "", false }, ObjectDeref: func(tgt *expr.AttributeExpr) (string, bool) { - // if the target is a raw struct no need to return a pointer + // An unnamed struct value does not need a pointer. if _, ok := tgt.Type.(*expr.Object); ok { return "", true } @@ -136,28 +125,9 @@ func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.Transf } } -// oneofMessageName returns the reference to the protoc generated Go type of -// the message containing the oneof being transformed: protoc builds the union -// wrapper struct type names from the parent message type name. parent is the -// attribute of the object owning the union field, nil when the union is -// transformed directly in which case there is no message context. -func oneofMessageName(parent *expr.AttributeExpr, proto bool, ta *codegen.TransformAttrs) string { - if parent == nil { - return "" - } - if _, ok := parent.Type.(expr.UserType); !ok { - return "" - } - if proto { - return ta.TargetCtx.Scope.Name(parent, ta.TargetCtx.Pkg(parent), false, false) - } - return ta.SourceCtx.Scope.Ref(parent, ta.SourceCtx.Pkg(parent)) -} - -// renderArrayTransform renders the code transforming the source array held by -// sourceVar into the target array held by targetVar. proto is true when the -// target is the protocol buffer type. Wrapped element types are passed -// through to the engine which unwraps them when recursing. +// renderArrayTransform writes code that copies sourceVar into the target array. +// proto is true when the target is a protobuf array. The shared conversion code +// opens any protobuf wrapper around an array element. func renderArrayTransform(source, target *expr.Array, sourceVar, targetVar string, newVar, proto bool, ta *codegen.TransformAttrs) (string, error) { elem := target.ElemType if proto { @@ -170,15 +140,16 @@ func renderArrayTransform(source, target *expr.Array, sourceVar, targetVar strin valVar = "" } + loopVar, childAttrs := ta.EnterCollection() data := map[string]any{ "ElemTypeRef": targetRef, "SourceElem": source.ElemType, - "TargetElem": elem, + "TargetElem": target.ElemType, "SourceVar": sourceVar, "TargetVar": targetVar, "NewVar": newVar, - "TransformAttrs": ta, - "LoopVar": string(rune(105 + strings.Count(targetVar, "["))), + "TransformAttrs": childAttrs, + "LoopVar": loopVar, "ValVar": valVar, } var buf bytes.Buffer @@ -188,11 +159,9 @@ func renderArrayTransform(source, target *expr.Array, sourceVar, targetVar strin return ensureTrailingNewline(buf.String()), nil } -// renderMapTransform renders the code transforming the source map held by -// sourceVar into the target map held by targetVar. proto is true when the -// target is the protocol buffer type. Wrapped element types are passed -// through to the engine which unwraps them when recursing; map keys cannot be -// nested in protocol buffers so only elements may be wrapped. +// renderMapTransform writes code that copies sourceVar into the target map. +// proto is true when the target is a protobuf map. Protobuf map values may use +// wrapper messages; protobuf map keys may not. func renderMapTransform(source, target *expr.Map, sourceVar, targetVar string, newVar, proto bool, ta *codegen.TransformAttrs) (string, error) { if err := codegen.IsCompatible(source.KeyType.Type, target.KeyType.Type, sourceVar+"[key]", targetVar+"[key]"); err != nil { return "", err @@ -226,11 +195,9 @@ func renderMapTransform(source, target *expr.Map, sourceVar, targetVar string, n return ensureTrailingNewline(buf.String()), nil } -// renderUnionToProtoTransform renders the code transforming the source Goa -// union held by sourceVar into the protoc generated oneof field held by -// targetVar. message is the reference to the protoc generated Go type of the -// message containing the oneof. -func renderUnionToProtoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string, sourcePtr bool, message string, ta *codegen.TransformAttrs) (string, error) { +// renderUnionToProtoTransform writes a service union from sourceVar into the +// protobuf oneof in targetVar. +func renderUnionToProtoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string, sourcePtr bool, ta *codegen.TransformAttrs) (string, error) { if err := codegen.IsCompatible(source.Type, target.Type, sourceVar, targetVar); err != nil { return "", err } @@ -239,10 +206,12 @@ func renderUnionToProtoTransform(source, target *expr.AttributeExpr, sourceVar, for i, sv := range src.Values { tv := tgt.Values[i] fieldName := ta.TargetCtx.Scope.Field(tv.Attribute, tv.Name, true) + scope := ta.TargetCtx.Scope.(protobufOneofAttributor) + wrapperType := scope.OneofWrapper(tv.Attribute) cases = append(cases, map[string]any{ "TypeTag": sv.Name, "SourceFieldName": codegen.Goify(sv.Name, true), - "TargetWrapperType": protocOneofWrapperRef(message, fieldName), + "TargetWrapperType": wrapperType, "TargetFieldName": fieldName, "ConvertedValue": convertType(sv.Attribute, tv.Attribute, false, false, "actual", true, ta), }) @@ -261,11 +230,9 @@ func renderUnionToProtoTransform(source, target *expr.AttributeExpr, sourceVar, return ensureTrailingNewline(buf.String()), nil } -// renderUnionFromProtoTransform renders the code transforming the protoc -// generated oneof field held by sourceVar into the target Goa union held by -// targetVar. message is the reference to the protoc generated Go type of the -// message containing the oneof. -func renderUnionFromProtoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar, message string, ta *codegen.TransformAttrs) (string, error) { +// renderUnionFromProtoTransform writes the protobuf oneof in sourceVar into the +// service union in targetVar. +func renderUnionFromProtoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string, ta *codegen.TransformAttrs) (string, error) { if err := codegen.IsCompatible(source.Type, target.Type, sourceVar, targetVar); err != nil { return "", err } @@ -274,8 +241,10 @@ func renderUnionFromProtoTransform(source, target *expr.AttributeExpr, sourceVar for i, sv := range src.Values { tv := tgt.Values[i] sourceFieldName := ta.SourceCtx.Scope.Field(sv.Attribute, sv.Name, true) + scope := ta.SourceCtx.Scope.(protobufOneofAttributor) + wrapperType := scope.OneofWrapper(sv.Attribute) cases = append(cases, map[string]any{ - "SourceValueTypeRef": protocOneofWrapperRef(message, sourceFieldName), + "SourceValueTypeRef": "*" + wrapperType, "TargetFieldName": codegen.Goify(tv.Name, true), "ConvertedValue": convertType(sv.Attribute, tv.Attribute, false, false, "val."+sourceFieldName, false, ta), }) @@ -292,9 +261,8 @@ func renderUnionFromProtoTransform(source, target *expr.AttributeExpr, sourceVar return ensureTrailingNewline(buf.String()), nil } -// ensureTrailingNewline appends a newline to code when missing so that the -// rendered transformations compose with the code the engine emits around -// them. +// ensureTrailingNewline appends a newline when surrounding generated code must +// continue on the next line. func ensureTrailingNewline(code string) string { if code != "" && !strings.HasSuffix(code, "\n") { code += "\n" diff --git a/grpc/codegen/proto_hooks_specialization_test.go b/grpc/codegen/proto_hooks_specialization_test.go new file mode 100644 index 0000000000..28559f27e3 --- /dev/null +++ b/grpc/codegen/proto_hooks_specialization_test.go @@ -0,0 +1,119 @@ +// This file verifies that gRPC collection conversions use the recorded nesting +// level to choose loop variables instead of examining generated Go expression +// text. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // protobufOneofSnapshotAttributor records the exact union branch used to + // resolve a protobuf wrapper name. + protobufOneofSnapshotAttributor struct { + codegen.Attributor + branches *[]*expr.AttributeExpr + } +) + +func TestRenderArrayTransformUsesTraversalDepthForLoopVariable(t *testing.T) { + source := &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.Int}} + target := &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.Int}} + sourceContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + targetContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + attributes := &codegen.TransformAttrs{ + SourceCtx: sourceContext, + TargetCtx: targetContext, + Hooks: protoHooks(true), + } + + generated, err := renderArrayTransform(source, target, "source", "target[key]", false, true, attributes) + require.NoError(t, err) + require.Contains(t, generated, "for i, val := range source") + require.Contains(t, generated, "target[key][i] =") +} + +func TestRenderArrayTransformPreservesPrimitiveAliasForElementConversion(t *testing.T) { + alias := &expr.UserTypeExpr{ + TypeName: "Alias", + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + } + source := &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}} + target := &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}} + sourceContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + targetContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + var convertedTarget *expr.AttributeExpr + attributes := &codegen.TransformAttrs{ + SourceCtx: sourceContext, + TargetCtx: targetContext, + Hooks: &codegen.TransformHooks{ + ConvertPrimitive: func(_ *expr.AttributeExpr, target *expr.AttributeExpr, _ string, _, _ bool, _ *codegen.TransformAttrs) (string, bool) { + convertedTarget = target + return "string(val)", true + }, + }, + } + + _, err := renderArrayTransform(source, target, "source", "target", true, true, attributes) + require.NoError(t, err) + require.Same(t, target.ElemType, convertedTarget) +} + +func TestTransformPlanOneofLookupUsesOriginalBranch(t *testing.T) { + sourceBranch := &expr.AttributeExpr{Type: expr.String} + targetBranch := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "SourceChoice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: sourceBranch}, + }, + }} + target := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "TargetChoice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: targetBranch}, + }, + }} + plan, err := codegen.NewTransformPlan(source, target, "", protoHooks(true)) + require.NoError(t, err) + require.Empty(t, plan.Helpers()) + + sourceContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + targetContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + var branches []*expr.AttributeExpr + targetContext.Scope = &protobufOneofSnapshotAttributor{ + Attributor: targetContext.Scope, + branches: &branches, + } + require.NoError(t, plan.BindContexts(sourceContext, targetContext)) + generated, definitions, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Empty(t, definitions) + require.Contains(t, generated, "TargetChoice_Text") + require.Len(t, branches, 1) + require.Same(t, targetBranch, branches[0]) +} + +// Enter preserves protobuf wrapper lookup while entering a copied union. +func (a *protobufOneofSnapshotAttributor) Enter(attribute *expr.AttributeExpr) codegen.Attributor { + return &protobufOneofSnapshotAttributor{ + Attributor: a.Attributor.Enter(attribute), + branches: a.branches, + } +} + +// IsSumType reports that this test resolver uses protobuf oneof wrappers. +func (*protobufOneofSnapshotAttributor) IsSumType() bool { + return false +} + +// OneofWrapper records the branch and returns its planned protobuf type. +func (a *protobufOneofSnapshotAttributor) OneofWrapper(attribute *expr.AttributeExpr) string { + *a.branches = append(*a.branches, attribute) + return "TargetChoice_Text" +} diff --git a/grpc/codegen/proto_test.go b/grpc/codegen/proto_test.go index 61cd9b9ca9..cfb2e9953a 100644 --- a/grpc/codegen/proto_test.go +++ b/grpc/codegen/proto_test.go @@ -1,3 +1,5 @@ +// This file verifies generated protobuf services and message declarations, +// including package-owned naming collisions, by compiling each schema. package codegen import ( @@ -35,12 +37,13 @@ func TestProtoFiles(t *testing.T) { {"protofiles-struct-meta-type", testdata.StructMetaTypeDSL}, {"protofiles-default-fields", testdata.DefaultFieldsDSL}, {"protofiles-custom-message-name", testdata.CustomMessageNameDSL}, + {"protofiles-distinct-custom-message-names", testdata.DistinctCustomMessageNamesDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ProtoFiles("", services) + fs := protoFiles(services) if len(fs) != 1 { t.Fatalf("got %d files, expected one", len(fs)) } @@ -50,7 +53,7 @@ func TestProtoFiles(t *testing.T) { // testutil.AssertString handles line ending normalization internally testutil.AssertString(t, "testdata/golden/proto_"+c.Name+".proto.golden", code) fpath := codegen.CreateTempFile(t, code) - assert.NoError(t, protoc(defaultProtocCmd, fpath, nil), "error occurred when compiling proto file %q", fpath) + assert.NoError(t, protoc(defaultProtocCmd, fpath), "error occurred when compiling proto file %q", fpath) }) } } @@ -75,7 +78,7 @@ func TestMessageDefSection(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ProtoFiles("", services) + fs := protoFiles(services) require.Len(t, fs, 1) sections := fs[0].SectionTemplates require.GreaterOrEqual(t, len(sections), 3) @@ -84,7 +87,7 @@ func TestMessageDefSection(t *testing.T) { // testutil.AssertString handles line ending normalization internally testutil.AssertString(t, "testdata/golden/proto_"+c.Name+".proto.golden", code+msgCode) fpath := codegen.CreateTempFile(t, code+msgCode) - assert.NoError(t, protoc(defaultProtocCmd, fpath, nil), "error occurred when compiling proto file %q", fpath) + assert.NoError(t, protoc(defaultProtocCmd, fpath), "error occurred when compiling proto file %q", fpath) }) } } @@ -118,7 +121,7 @@ func TestProtoc(t *testing.T) { t.Cleanup(func() { assert.NoError(t, os.RemoveAll(dir)) }) fpath := filepath.Join(dir, "schema") require.NoError(t, os.WriteFile(fpath, []byte(code), 0o600), "error occurred writing proto schema") - require.NoError(t, protoc(c.Cmd, fpath, nil), "error occurred when compiling proto file with the standard protoc %q", fpath) + require.NoError(t, protoc(c.Cmd, fpath), "error occurred when compiling proto file with the standard protoc %q", fpath) fcontents, err := os.ReadFile(fpath + ".pb.go") require.NoError(t, err) diff --git a/grpc/codegen/protobuf.go b/grpc/codegen/protobuf.go index 7474d7a680..01881ccb12 100644 --- a/grpc/codegen/protobuf.go +++ b/grpc/codegen/protobuf.go @@ -1,8 +1,9 @@ +// This file defines protobuf-specific attribute naming used by gRPC type, +// validation, and transformation generation. package codegen import ( "fmt" - "regexp" "slices" "strconv" "strings" @@ -13,9 +14,11 @@ import ( ) type ( - // protoBufScope is the scope for protocol buffer attribute types. + // protoBufScope supplies the Go names used for protobuf fields and types in + // one generated service package. protoBufScope struct { - scope *codegen.NameScope + service *ServiceData + pkg string } ) @@ -33,30 +36,69 @@ const ( // Name returns the protocol buffer type name. func (p *protoBufScope) Name(att *expr.AttributeExpr, pkg string, _, _ bool) string { - return protoBufGoFullTypeName(att, pkg, p.scope) + return protoBufGoFullTypeName(att, pkg, p.service) } // Ref returns the protocol buffer type reference. func (p *protoBufScope) Ref(att *expr.AttributeExpr, pkg string) string { - return protoBufGoFullTypeRef(att, pkg, p.scope) + return protoBufGoFullTypeRef(att, pkg, p.service) } -// Field returns the field name as generated by protocol buffer compiler. -// NOTE: protoc does not care about common initialisms like api -> API so we -// first transform the name into snake case to end up with Api. -func (*protoBufScope) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { - return protoBufifyAtt(att, codegen.SnakeCase(name), firstUpper) +// Package returns the protocol buffer package qualifier for att. +func (p *protoBufScope) Package(*expr.AttributeExpr) string { + return p.pkg } -// Scope returns the name scope. +// Enter keeps nested protobuf messages in the same generated package. +func (p *protoBufScope) Enter(*expr.AttributeExpr) codegen.Attributor { + return p +} + +// IsSumType reports that protobuf unions use generated oneof messages rather +// than Goa service values that hold one selected branch. +func (*protoBufScope) IsSumType() bool { + return false +} + +// ValidatorCall returns a call using the first choice for a protobuf validation +// function name. The service may add a suffix when another function uses it. +func (p *protoBufScope) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + name := "Validate" + p.Name(att, "", false, true) + codegen.Goify(view, true) + return fmt.Sprintf("%s(%s)", name, target) +} + +// Field returns the exact Go field name produced by the supported protobuf +// tools. +func (p *protoBufScope) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { + planned, ok := p.service.protobuf.plan.fieldName(att) + if !ok { + panic(fmt.Sprintf("protobuf field %q was not planned", name)) + } + return planned +} + +// OneofWrapper returns the generated wrapper type for one branch in one +// parent message. +func (p *protoBufScope) OneofWrapper(attribute *expr.AttributeExpr) string { + name, ok := p.service.protobuf.plan.wrapperName(attribute) + if !ok { + panic("protobuf oneof branch was not planned") + } + if p.pkg == "" { + return name + } + return p.pkg + "." + name +} + +// Scope returns the object that assigns unique Go names in this package. func (p *protoBufScope) Scope() *codegen.NameScope { - return p.scope + return p.service.Scope } // protoBufTypeContext returns a contextual attribute for the protocol buffer type. -func protoBufTypeContext(pkg string, scope *codegen.NameScope, useDefault bool) *codegen.AttributeContext { - ctx := codegen.NewAttributeContext(false, true, useDefault, pkg, scope) - ctx.Scope = &protoBufScope{scope: scope} +func protoBufTypeContext(pkg string, service *ServiceData, useDefault bool) *codegen.AttributeContext { + ctx := codegen.NewAttributeContext(false, true, useDefault, pkg, service.Scope) + ctx.Scope = &protoBufScope{service: service, pkg: pkg} return ctx } @@ -66,61 +108,54 @@ func protoBufTypeContext(pkg string, scope *codegen.NameScope, useDefault bool) // map, it wraps the given attribute with an object with a single "field" // attribute. For nested arrays/maps, the inner array/map is wrapped into a // user type. -func makeProtoBufMessage(att *expr.AttributeExpr, tname string, sd *ServiceData) *expr.AttributeExpr { +func makeProtoBufMessage(att *expr.AttributeExpr, tname string, owner expr.ExampleIdentity) *expr.AttributeExpr { att = expr.DupAtt(att) expr.RemovePkgPath(att) ut, isut := att.Type.(expr.UserType) switch { case att.Type == expr.Empty: - att.Type = &expr.UserTypeExpr{ - TypeName: tname, - AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}, - UID: sd.Name + "#" + tname, - } + att.Type = expr.NewGeneratedUserType(tname, &expr.AttributeExpr{Type: &expr.Object{}}, owner) return att case expr.IsPrimitive(att.Type): - wrapAttr(att, tname, true, sd) + wrapAttr(att, tname, true, owner) return att case isut: - if expr.IsArray(ut) { - wrapAttr(att, tname, false, sd) + if expr.IsArray(ut) || expr.IsMap(ut) { + wrapAttr(att, tname, false, owner) } case expr.IsArray(att.Type) || expr.IsMap(att.Type): - wrapAttr(att, tname, false, sd) + wrapAttr(att, tname, false, owner) case expr.IsObject(att.Type) || expr.IsUnion(att.Type): - att.Type = &expr.UserTypeExpr{ - TypeName: tname, - AttributeExpr: expr.DupAtt(att), - UID: sd.Name + "#" + tname, - } + att.Type = expr.NewGeneratedUserType(tname, expr.DupAtt(att), owner) } n := "" - makeProtoBufMessageR(att, &n, sd, make(map[string]struct{})) + makeProtoBufMessageR(att, &n, owner, make(map[expr.UserType]struct{})) return att } // makeProtoBufMessageR is the recursive implementation of makeProtoBufMessage. -func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, sd *ServiceData, seen map[string]struct{}) { +func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, owner expr.ExampleIdentity, seen map[expr.UserType]struct{}) { ut, isut := att.Type.(expr.UserType) // handle infinite recursions if isut { - if _, ok := seen[ut.ID()]; ok { + origin := ut.Origin() + if _, ok := seen[origin]; ok { return } - seen[ut.ID()] = struct{}{} + seen[origin] = struct{}{} } wrap := func(att *expr.AttributeExpr, tname string) { switch { case expr.IsArray(att.Type): wrapAttr(att, "ArrayOf"+tname+ - protoBufify(protoBufMessageDef(expr.AsArray(att.Type).ElemType, sd), true, true), true, sd) + codegen.ProtobufName(protoBufShapeTypeName(expr.AsArray(att.Type).ElemType)), true, owner) case expr.IsMap(att.Type): m := expr.AsMap(att.Type) wrapAttr(att, tname+"MapOf"+ - protoBufify(protoBufMessageDef(m.KeyType, sd), true, true)+ - protoBufify(protoBufMessageDef(m.ElemType, sd), true, true), true, sd) + codegen.ProtobufName(protoBufShapeTypeName(m.KeyType))+ + codegen.ProtobufName(protoBufShapeTypeName(m.ElemType)), true, owner) } } @@ -128,32 +163,37 @@ func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, sd *ServiceDat case expr.IsPrimitive(att.Type): return case isut: - if expr.IsArray(ut) { - wrapAttr(ut.Attribute(), ut.Name(), false, sd) + switch { + case expr.IsArray(ut): + wrapAttr(ut.Attribute(), ut.Name(), false, expr.GRPCArrayWrapperExampleIdentity(ut)) + case expr.IsMap(ut): + wrapAttr(ut.Attribute(), ut.Name(), false, expr.GRPCMapWrapperExampleIdentity(ut)) } - makeProtoBufMessageR(ut.Attribute(), tname, sd, seen) + makeProtoBufMessageR(ut.Attribute(), tname, owner, seen) case expr.IsArray(att.Type): ar := expr.AsArray(att.Type) - makeProtoBufMessageR(ar.ElemType, tname, sd, seen) + elementOwner := owner.ArrayElement(0) + makeProtoBufMessageR(ar.ElemType, tname, elementOwner, seen) wrap(ar.ElemType, *tname) case expr.IsMap(att.Type): m := expr.AsMap(att.Type) - makeProtoBufMessageR(m.ElemType, tname, sd, seen) + valueOwner := owner.MapValue(0) + makeProtoBufMessageR(m.ElemType, tname, valueOwner, seen) wrap(m.ElemType, *tname) case expr.IsUnion(att.Type): for _, nat := range expr.AsUnion(att.Type).Values { - makeProtoBufMessageR(nat.Attribute, tname, sd, seen) + makeProtoBufMessageR(nat.Attribute, tname, owner.UnionMember(nat.Name), seen) } case expr.IsObject(att.Type): for _, nat := range *(expr.AsObject(att.Type)) { - makeProtoBufMessageR(nat.Attribute, tname, sd, seen) + makeProtoBufMessageR(nat.Attribute, tname, owner.Member(nat.Name), seen) } } } // wrapAttr makes the attribute type a user type by wrapping the given // attribute into an attribute named "field". -func wrapAttr(att *expr.AttributeExpr, tname string, req bool, sd *ServiceData) { +func wrapAttr(att *expr.AttributeExpr, tname string, req bool, owner expr.ExampleIdentity) { wrap := func(attr *expr.AttributeExpr) *expr.AttributeExpr { res := &expr.AttributeExpr{ Type: &expr.Object{ @@ -178,15 +218,9 @@ func wrapAttr(att *expr.AttributeExpr, tname string, req bool, sd *ServiceData) switch dt := att.Type.(type) { case expr.UserType: // Don't change the original user type. Create a copy and wrap that. - ut := expr.Dup(dt).(expr.UserType) - ut.SetAttribute(wrap(ut.Attribute())) - att.Type = ut + att.Type = expr.NewGeneratedUserType(dt.Name(), wrap(expr.DupAtt(dt.Attribute())), owner) default: - att.Type = &expr.UserTypeExpr{ - TypeName: tname, - AttributeExpr: wrap(att), - UID: sd.Name + "#" + tname, - } + att.Type = expr.NewGeneratedUserType(tname, wrap(att), owner) } // Validation is moved to wrapped attribute. att.Validation = nil @@ -234,40 +268,55 @@ func unwrapAttr(att *expr.AttributeExpr) *expr.AttributeExpr { // protoBufMessageName returns the protocol buffer message name of the given // attribute type. -func protoBufMessageName(att *expr.AttributeExpr, s *codegen.NameScope) string { - return protoBufFullMessageName(att, "", s) +func protoBufMessageName(att *expr.AttributeExpr, service *ServiceData) string { + return protoBufFullMessageName(att, "", service) +} + +// protoBufSourceMessageName returns the message name written to the .proto +// file. +func protoBufSourceMessageName(att *expr.AttributeExpr, service *ServiceData) string { + userType, ok := att.Type.(expr.UserType) + if !ok { + if composite, ok := att.Type.(expr.CompositeExpr); ok { + return protoBufSourceMessageName(composite.Attribute(), service) + } + panic(fmt.Sprintf("data type is not a protobuf message: received type %T", att.Type)) // bug + } + record := service.protobuf.message(att) + if record == nil { + panic(fmt.Sprintf("protobuf message %q has no planned name", userType.Name())) + } + return record.protoName } // protoBufFullMessageName returns the protocol buffer message name of the // given user type qualified with the given package name if applicable. -func protoBufFullMessageName(att *expr.AttributeExpr, pkg string, s *codegen.NameScope) string { +func protoBufFullMessageName(att *expr.AttributeExpr, pkg string, service *ServiceData) string { switch actual := att.Type.(type) { - case expr.UserType, *expr.Union: - n := s.HashedUnique(actual, protoBufify(actual.Name(), true, true), "") - if name := att.Meta["struct:name:proto"]; len(name) > 0 { - n = name[0] + case expr.UserType: + if service.protobuf == nil { + panic(fmt.Sprintf("protobuf message %q has no package catalog", actual.Name())) + } + record := service.protobuf.message(att) + if record == nil { + panic(fmt.Sprintf("protobuf message %q has no frozen declaration", actual.Name())) } + n := record.name if pkg == "" { return n } return pkg + "." + n case expr.CompositeExpr: - return protoBufFullMessageName(actual.Attribute(), pkg, s) + return protoBufFullMessageName(actual.Attribute(), pkg, service) default: - panic(fmt.Sprintf("data type is not a user type or union: received type %T", actual)) // bug + panic(fmt.Sprintf("data type is not a protobuf message: received type %T", actual)) // bug } } -// protoBufGoTypeName returns the protocol buffer type name for the given -// attribute generated after compiling the proto file (in *.pb.go). -func protoBufGoTypeName(att *expr.AttributeExpr, s *codegen.NameScope) string { - return protoBufGoFullTypeName(att, "", s) -} - // protoBufGoFullTypeName returns the protocol buffer type name qualified with // the given package name for the given attribute generated after compiling // the proto file (in *.pb.go). -func protoBufGoFullTypeName(att *expr.AttributeExpr, pkg string, s *codegen.NameScope) string { +func protoBufGoFullTypeName(att *expr.AttributeExpr, pkg string, service *ServiceData) string { if proto := att.Meta["struct:field:proto"]; len(proto) > 2 { typ := proto[2] if len(att.Meta["struct:field:proto"]) > 3 { @@ -276,24 +325,68 @@ func protoBufGoFullTypeName(att *expr.AttributeExpr, pkg string, s *codegen.Name } return typ } + if primitive := getPrimitive(att); primitive != nil { + return protoBufGoFullTypeName(primitive, pkg, service) + } switch actual := att.Type.(type) { - case expr.UserType, expr.CompositeExpr, *expr.Union: - return protoBufFullMessageName(att, pkg, s) + case *expr.Union: + if service.protobuf == nil { + panic(fmt.Sprintf("protobuf oneof %q has no package catalog", actual.Name())) + } + name := service.protobuf.unionName(att) + if pkg == "" { + return name + } + return pkg + "." + name + case expr.UserType, expr.CompositeExpr: + return protoBufFullMessageName(att, pkg, service) case expr.Primitive: return protoBufNativeGoTypeName(att.Type) case *expr.Array: - return "[]" + protoBufGoFullTypeRef(actual.ElemType, pkg, s) + return "[]" + protoBufGoFullTypeRef(actual.ElemType, pkg, service) case *expr.Map: return fmt.Sprintf("map[%s]%s", - protoBufGoFullTypeRef(actual.KeyType, pkg, s), - protoBufGoFullTypeRef(actual.ElemType, pkg, s)) + protoBufGoFullTypeRef(actual.KeyType, pkg, service), + protoBufGoFullTypeRef(actual.ElemType, pkg, service)) case *expr.Object: - return s.GoTypeDef(att, false, false) + return service.Scope.GoTypeDef(att, false, false) default: panic(fmt.Sprintf("unknown data type %T", actual)) // bug } } +// protoBufShapeTypeName returns the type name used when Goa wraps an array or +// map inside a protobuf message. It reads the design but does not reserve a Go +// name for the generated message. +func protoBufShapeTypeName(att *expr.AttributeExpr) string { + if protos := att.Meta["struct:field:proto"]; len(protos) > 0 { + return protos[0] + } + switch actual := att.Type.(type) { + case expr.Primitive: + return protoNativeType(actual) + case expr.UserType: + if names := att.Meta["struct:name:proto"]; len(names) > 0 { + return names[0] + } + if names := actual.Attribute().Meta["struct:name:proto"]; len(names) > 0 { + return names[0] + } + return codegen.ProtobufName(actual.Name()) + case expr.CompositeExpr: + return protoBufShapeTypeName(actual.Attribute()) + case *expr.Object: + return "Object" + case *expr.Union: + if actual.TypeName != "" { + return codegen.ProtobufName(actual.TypeName) + } + return "Union" + default: + panic(fmt.Sprintf("unknown protobuf shaping type %T", actual)) // bug + } +} + // protoType returns the protocol buffer type name for the given attribute. func protoType(att *expr.AttributeExpr, sd *ServiceData) string { if protos := att.Meta["struct:field:proto"]; len(protos) > 0 { @@ -314,15 +407,22 @@ func protoBufMessageDef(att *expr.AttributeExpr, sd *ServiceData) string { case *expr.Map: return fmt.Sprintf("map<%s, %s>", protoType(actual.KeyType, sd), protoType(actual.ElemType, sd)) case *expr.Union: - // Compute oneof name and ensure it does not collide with any of the member field names - oneofName := codegen.SnakeCase(protoBufify(actual.Name(), false, false)) + oneofName := codegen.ProtobufFieldName(actual.Name()) + if sd.protobuf != nil { + oneofName = sd.protobuf.plan.sourceOneofName(att) + } var fieldNames []string for _, nat := range actual.Values { - fn := codegen.SnakeCase(protoBufify(nat.Name, false, false)) + fn := protobufSourceFieldName(nat.Name) + if sd.protobuf != nil { + fn = sd.protobuf.plan.sourceFieldName(nat.Attribute) + } fieldNames = append(fieldNames, fn) } - for slices.Contains(fieldNames, oneofName) { - oneofName += "_oneof" + if sd.protobuf == nil { + for slices.Contains(fieldNames, oneofName) { + oneofName += "_oneof" + } } def := "\toneof " + oneofName + " {" for i, nat := range actual.Values { @@ -350,7 +450,7 @@ func protoBufMessageDef(att *expr.AttributeExpr, sd *ServiceData) string { if prim := getPrimitive(att); prim != nil { return protoBufMessageDef(prim, sd) } - return protoBufMessageName(att, sd.Scope) + return protoBufSourceMessageName(att, sd) case *expr.Object: var ss []string ss = append(ss, " {") @@ -367,7 +467,10 @@ func protoBufMessageDef(att *expr.AttributeExpr, sd *ServiceData) string { desc string ) { - fn = codegen.SnakeCase(protoBufify(nat.Name, false, false)) + fn = protobufSourceFieldName(nat.Name) + if sd.protobuf != nil { + fn = sd.protobuf.plan.sourceFieldName(nat.Attribute) + } fnum = rpcTag(nat.Attribute) if prim := getPrimitive(nat.Attribute); prim != nil { typ = protoType(prim, sd) @@ -404,71 +507,14 @@ func protoJSONOption(att *expr.AttributeExpr) string { // protoBufGoFullTypeRef returns the Go code qualified with package name that // refers to the Go type generated by compiling the protocol buffer // (in *.pb.go) for the given attribute. -func protoBufGoFullTypeRef(att *expr.AttributeExpr, pkg string, s *codegen.NameScope) string { - name := protoBufGoFullTypeName(att, pkg, s) +func protoBufGoFullTypeRef(att *expr.AttributeExpr, pkg string, service *ServiceData) string { + name := protoBufGoFullTypeName(att, pkg, service) if expr.IsObject(att.Type) || expr.IsUnion(att.Type) { return "*" + name } return name } -var digits = regexp.MustCompile("[0-9]+") - -// protoBufify makes a valid protocol buffer identifier out of any string. -// It does that by removing any non letter and non digit character and by -// making sure the first character is a letter or "_". protoBufify produces a -// "CamelCase" version of the string. -// -// If firstUpper is true the first character of the identifier is uppercase -// otherwise it's lowercase. -func protoBufify(str string, firstUpper, acronym bool) string { - // Optimize trivial case - if str == "" { - return "" - } - - // Remove optional suffix that defines corresponding transport specific - // name. - idx := strings.Index(str, ":") - if idx > 0 { - str = str[:idx] - } - - // The CamelCase implementation of protoc-gen-go considers digits as words - // but our CamelCase implementation considers them as lower case characters, - // compensate by adding an underscore after any series of digits. - // See https://github.com/golang/protobuf/blob/d04d7b157bb510b1e0c10132224b616ac0e26b17/protoc-gen-go/generator/generator.go#L2648-L2685 - str = string(digits.ReplaceAllFunc([]byte(str), func(match []byte) []byte { - res := make([]byte, len(match)+1) // need to allocate new slice - copy(res, match) - res[len(res)-1] = '_' - return res - })) - - str = codegen.CamelCase(str, firstUpper, acronym) - if str == "" { - // All characters are invalid. Produce a default value. - if firstUpper { - return "Val" - } - return "val" - } - - return fixReservedProtoBuf(str) -} - -// protoBufifyAtt honors any struct:field:name meta set on the attribute and -// and calls protoBufify with the tag value if present or the given name -// otherwise. -func protoBufifyAtt(att *expr.AttributeExpr, name string, upper bool) string { - if tname, ok := att.Meta["struct:field:name"]; ok { - if len(tname) > 0 { - name = tname[0] - } - } - return protoBufify(name, upper, false) -} - // protoNativeType returns the protocol buffer built-in type // corresponding to the given primitive type. It panics if t is not a // primitive type. @@ -552,50 +598,3 @@ func rpcTag(a *expr.AttributeExpr) uint64 { } return tag } - -// fixReservedProtoBuf appends an underscore on to protocol buffer reserved -// keywords. -func fixReservedProtoBuf(w string) string { - if _, ok := reservedProtoBuf[codegen.CamelCase(w, false, false)]; ok { - w += "_" - } - return w -} - -var ( - // reserved protocol buffer keywords and package names - reservedProtoBuf = map[string]struct{}{ - // types - "bool": {}, - "bytes": {}, - "double": {}, - "fixed32": {}, - "fixed64": {}, - "float": {}, - "int32": {}, - "int64": {}, - "sfixed32": {}, - "sfixed64": {}, - "sint32": {}, - "sint64": {}, - "string": {}, - "uint32": {}, - "uint64": {}, - - // reserved - "enum": {}, - "import": {}, - "map": {}, - "message": {}, - "oneof": {}, - "option": {}, - "package": {}, - "public": {}, - "repeated": {}, - "reserved": {}, - "returns": {}, - "rpc": {}, - "service": {}, - "syntax": {}, - } -) diff --git a/grpc/codegen/protobuf_catalog.go b/grpc/codegen/protobuf_catalog.go new file mode 100644 index 0000000000..12cbfed8ea --- /dev/null +++ b/grpc/codegen/protobuf_catalog.go @@ -0,0 +1,896 @@ +// This file records the protobuf messages and validation functions written for +// one gRPC service. It chooses every package-level name before conversion code +// refers to that name. +package codegen + +import ( + "fmt" + "reflect" + "regexp" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // protobufPackageCatalog stores every message and validation function that + // Goa writes for one service. + protobufPackageCatalog struct { + packageName string + plan *protobufServicePlan + messages []*protobufMessageRecord + messageUses map[*expr.AttributeExpr]*protobufMessageRecord + unions []*protobufUnionRecord + unionUses map[*expr.AttributeExpr]*protobufUnionRecord + rootSources map[expr.UserType]protobufMessageSource + validators []*protobufValidationRecord + validationUses map[protobufValidationUse]*protobufValidationRecord + frozen bool + messagesRendered bool + validationsFrozen bool + } + + // protobufEndpointMessages contains the copied request, response, and error + // values used to write one endpoint's protobuf code. + protobufEndpointMessages struct { + request *expr.AttributeExpr + streamingRequest *expr.AttributeExpr + requestEnvelope *expr.AttributeExpr + response *expr.AttributeExpr + errors map[string]*expr.AttributeExpr + } + + // protobufMessageRecord stores one protobuf message and every place that + // uses it. + protobufMessageRecord struct { + identity protobufMessageIdentity + uses []*expr.AttributeExpr + protoName string + plannedName string + declaration *codegen.NameDeclaration + name string + goRef string + data *service.UserTypeData + } + + // This record stores the source declaration, requested name, explicit-name + // flag, and type fields used to decide whether two values can share one + // protobuf message. + protobufMessageIdentity struct { + source protobufMessageSource + preferredName string + explicitName bool + userType expr.UserType + attribute *expr.AttributeExpr + } + + // protobufUnionRecord stores one oneof inside its protobuf message. + protobufUnionRecord struct { + owner *protobufMessageRecord + attribute *expr.AttributeExpr + fieldName string + uses []*expr.AttributeExpr + name string + } + + // protobufMessageSource points to either an authored declaration or an + // endpoint message created by the generator. + protobufMessageSource struct { + origin expr.UserType + synthetic protobufSyntheticMessage + } + + // protobufSyntheticMessage stores a message that Goa creates for an endpoint. + protobufSyntheticMessage struct { + endpoint *expr.GRPCEndpointExpr + error *expr.GRPCErrorExpr + role protobufSyntheticRole + } + + // protobufSyntheticRole says which endpoint value a Goa-created message holds. + protobufSyntheticRole uint8 + + // protobufValidationRecord stores one validation function written to a + // client or server package. + protobufValidationRecord struct { + message *protobufMessageRecord + declaration *codegen.NameDeclaration + attribute *expr.AttributeExpr + source protobufValidationSource + side validateKind + targetName string + contextName string + uses []*expr.AttributeExpr + data *ValidationData + } + + // protobufValidationSource records the endpoint value and nested field that + // first needs one validation function. + protobufValidationSource struct { + api string + service string + method string + error string + path string + role protobufValidationRole + } + + // protobufValidationRole says which endpoint value is checked. + protobufValidationRole uint8 + + // protobufValidationUse records which function checks one copied value in a + // generated client or server package. + protobufValidationUse struct { + attribute *expr.AttributeExpr + side validateKind + } + + // protobufAttributePair stops a comparison when recursive values lead back + // to the same pair. + protobufAttributePair struct { + left *expr.AttributeExpr + right *expr.AttributeExpr + } + + // This value supplies the protobuf message names and validation function + // names used while Goa writes validation code. + protobufValidationScope struct { + *protoBufScope + catalog *protobufPackageCatalog + side validateKind + message *protobufMessageRecord + parent expr.UserType + } +) + +const ( + protobufRequestMessage protobufSyntheticRole = iota + 1 + protobufStreamingRequestMessage + protobufStreamEnvelopeMessage + protobufResponseMessage + protobufErrorMessage + protobufWrapperMessage +) + +var protobufExactNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +const ( + protobufRequestValidation protobufValidationRole = iota + 1 + protobufResponseValidation + protobufErrorValidation + protobufStreamingRequestValidation +) + +// newProtobufPackageCatalog creates the protobuf messages for one service and +// the functions that validate those messages in its client and server. +func newProtobufPackageCatalog(packageName string) *protobufPackageCatalog { + return &protobufPackageCatalog{ + packageName: packageName, + messageUses: make(map[*expr.AttributeExpr]*protobufMessageRecord), + unionUses: make(map[*expr.AttributeExpr]*protobufUnionRecord), + rootSources: make(map[expr.UserType]protobufMessageSource), + validationUses: make(map[protobufValidationUse]*protobufValidationRecord), + } +} + +// bindRootSource records which service or endpoint value a generated top-level +// message carries. Copies of that message use the same information. +func (c *protobufPackageCatalog) bindRootSource(attribute *expr.AttributeExpr, source protobufMessageSource) { + if attribute.Type == expr.Empty { + return + } + userType, ok := attribute.Type.(expr.UserType) + if !ok || (source.origin == nil && source.synthetic.role == 0) { + return + } + c.rootSources[userType.Origin()] = source +} + +// collectMessage records every protobuf message reachable from an endpoint's +// request, response, or error attribute. source identifies that top-level +// attribute. Nested user types keep their own declarations. +func (c *protobufPackageCatalog) collectMessage(attribute *expr.AttributeExpr, source protobufMessageSource) error { + if c.frozen { + panic("cannot collect a protobuf message after the package catalog is frozen") + } + return c.collectMessageRecursive(attribute, source, true, nil, "") +} + +// freezeMessages chooses the final protobuf and Go names for every message. It +// then connects each copied value to its message and prepares template data. +func (c *protobufPackageCatalog) freezeMessages(sd *ServiceData) []*service.UserTypeData { + c.freezeMessageNames() + if c.messagesRendered { + return c.messageData() + } + c.messagesRendered = true + for _, record := range c.messages { + identity := record.identity + userType := identity.userType + definition := protoBufMessageDef(userTypeAttribute(userType), sd) + for _, use := range record.uses[1:] { + other := protoBufMessageDef(userTypeAttribute(use.Type.(expr.UserType)), sd) + if other != definition { + panic(fmt.Sprintf("protobuf declaration %q has one typed identity but different wire definitions", record.name)) + } + } + record.data = &service.UserTypeData{ + Name: record.name, + VarName: record.name, + Description: userType.Attribute().Description, + Def: definition, + Ref: record.goRef, + Type: userType, + } + } + return c.messageData() +} + +// freezeMessageNames chooses the final package-level name for every message +// without creating its .proto definition. Conversion code uses this when it +// needs the Go type name but another service writes the message. +func (c *protobufPackageCatalog) freezeMessageNames() { + if c.frozen { + return + } + c.frozen = true + if c.plan == nil { + panic("protobuf message names were not planned") + } + for _, record := range c.messages { + record.name = record.declaration.Name() + record.goRef = "*" + c.packageName + "." + record.name + } + for _, record := range c.unions { + record.name = c.plan.oneofInterfaceName(record) + } +} + +// collectValidation records the validation function needed for attribute and +// every nested protobuf message it can call. +func (c *protobufPackageCatalog) collectValidation(attribute *expr.AttributeExpr, side validateKind, source protobufValidationSource, targetName, contextName string) { + if c.validationsFrozen { + panic("cannot collect a protobuf validator after validators freeze") + } + c.collectValidationRecursive(attribute, side, source, targetName, contextName, make(map[*protobufValidationRecord]struct{})) +} + +// freezeValidations builds each validation function with the name already +// chosen for its generated client or server package. +func (c *protobufPackageCatalog) freezeValidations(sd *ServiceData) []*ValidationData { + if c.validationsFrozen { + return c.validationData() + } + c.validationsFrozen = true + for _, record := range c.validators { + if record.declaration == nil { + panic(fmt.Sprintf("protobuf validator for %q has no generated declaration", record.message.plannedName)) + } + validationAttribute := expr.DupAtt(record.attribute) + c.plan.bindAttributeCopy(record.attribute, validationAttribute) + c.bindCopiedValidationUses(record.attribute, validationAttribute, record.side) + removeMeta(validationAttribute) + userType := validationAttribute.Type.(expr.UserType) + context := protoBufTypeContext(c.packageName, sd, false) + context.Scope = &protobufValidationScope{ + protoBufScope: context.Scope.(*protoBufScope), + catalog: c, + side: record.side, + message: record.message, + parent: userType, + } + definition := codegen.AttributeValidationCode( + userTypeAttribute(userType), + userType, + context, + true, + false, + record.targetName, + record.contextName, + ) + if definition == "" { + continue + } + record.data = &ValidationData{ + Declaration: record.declaration, + Name: record.declaration.Name(), + Def: definition, + ArgName: record.targetName, + SrcName: record.message.name, + SrcRef: record.message.goRef, + Kind: record.side, + } + } + return c.validationData() +} + +// message returns the completed message used for attribute. +func (c *protobufPackageCatalog) message(attribute *expr.AttributeExpr) *protobufMessageRecord { + if !c.frozen { + panic("cannot resolve a protobuf message before the package catalog freezes") + } + return c.messageRecord(attribute) +} + +// messageRecord returns the protobuf message collected for attribute. It may +// be called before package names are fixed. +func (c *protobufPackageCatalog) messageRecord(attribute *expr.AttributeExpr) *protobufMessageRecord { + return c.messageUses[attribute] +} + +// unionName returns the chosen Go interface name for one oneof declaration. +func (c *protobufPackageCatalog) unionName(attribute *expr.AttributeExpr) string { + if !c.frozen { + panic("cannot resolve a protobuf oneof before the package catalog freezes") + } + record := c.unionUses[attribute] + if record == nil { + panic(fmt.Sprintf("protobuf oneof %q has no frozen declaration", attribute.Type.Name())) + } + return record.name +} + +// validation returns the completed validation function used for attribute in +// the client or server package. +func (c *protobufPackageCatalog) validation(attribute *expr.AttributeExpr, side validateKind) *ValidationData { + if !c.validationsFrozen { + panic("cannot resolve a protobuf validator before validators freeze") + } + record := c.validationUses[protobufValidationUse{attribute: attribute, side: side}] + if record == nil { + return nil + } + return record.data +} + +// Ref returns the current message name when union validation asks for its +// parent type. Other values use the protobuf package records. +func (s *protobufValidationScope) Ref(attribute *expr.AttributeExpr, pkg string) string { + if attribute.Type == s.parent { + name := s.message.name + if pkg != "" { + name = pkg + "." + name + } + return "*" + name + } + return s.protoBufScope.Ref(attribute, pkg) +} + +// ValidatorCall returns a call to the validation function written to the +// current client or server package. +func (s *protobufValidationScope) ValidatorCall(attribute *expr.AttributeExpr, _, target, _ string) string { + validator := s.catalog.validationRecord(attribute, s.side) + if validator == nil { + panic("protobuf validator was not retained") + } + return fmt.Sprintf("%s(%s)", validator.declaration.Name(), target) +} + +// collectMessageRecursive records messages and oneofs. Existing message +// records stop recursive user types. +func (c *protobufPackageCatalog) collectMessageRecursive(attribute *expr.AttributeExpr, source protobufMessageSource, root bool, owner *protobufMessageRecord, fieldName string) error { + if attribute == nil { + return nil + } + if expr.IsPrimitive(attribute.Type) { + return nil + } + switch actual := attribute.Type.(type) { + case expr.UserType: + origin := actual.Origin() + identitySource := protobufMessageSource{origin: origin} + if rootSource, ok := c.rootSources[origin]; ok { + identitySource = rootSource + } + if !root && len(actual.Attribute().Meta[wrappedAttrMeta]) > 0 { + identitySource = protobufMessageSource{synthetic: protobufSyntheticMessage{ + role: protobufWrapperMessage, + }} + } + if root && (source.origin != nil || source.synthetic.role != 0) { + identitySource = source + c.rootSources[origin] = source + } + identity, err := protobufMessageIdentityFor(attribute, identitySource) + if err != nil { + return err + } + record := c.findMessage(identity) + if record != nil { + record.uses = append(record.uses, attribute) + c.messageUses[attribute] = record + c.bindCopiedMessageUses(record.uses[0], attribute) + return nil + } + record = &protobufMessageRecord{identity: identity, uses: []*expr.AttributeExpr{attribute}} + c.messages = append(c.messages, record) + c.messageUses[attribute] = record + return c.collectMessageRecursive(userTypeAttribute(actual), protobufMessageSource{}, false, record, "") + case *expr.Object: + for _, named := range *actual { + if err := c.collectMessageRecursive(named.Attribute, protobufMessageSource{}, false, owner, named.Name); err != nil { + return err + } + } + case *expr.Array: + return c.collectMessageRecursive(actual.ElemType, protobufMessageSource{}, false, owner, fieldName+"Elem") + case *expr.Map: + if err := c.collectMessageRecursive(actual.KeyType, protobufMessageSource{}, false, owner, fieldName+"Key"); err != nil { + return err + } + return c.collectMessageRecursive(actual.ElemType, protobufMessageSource{}, false, owner, fieldName+"Elem") + case *expr.Union: + if owner == nil { + panic(fmt.Sprintf("protobuf oneof %q has no owning message", actual.Name())) + } + if fieldName == "" { + fieldName = actual.Name() + } + record := c.findUnion(owner, fieldName, attribute) + if record == nil { + record = &protobufUnionRecord{ + owner: owner, + attribute: attribute, + fieldName: fieldName, + } + c.unions = append(c.unions, record) + } + record.uses = append(record.uses, attribute) + c.unionUses[attribute] = record + for _, named := range actual.Values { + if err := c.collectMessageRecursive(named.Attribute, protobufMessageSource{}, false, owner, fieldName+named.Name); err != nil { + return err + } + } + } + return nil +} + +// bindCopiedMessageUses records the message, choice, field, and wrapper names +// for every part of a copied protobuf value. +func (c *protobufPackageCatalog) bindCopiedMessageUses(original, copy *expr.AttributeExpr) { + walkProtobufCopy(original, copy, func(original, copy *expr.AttributeExpr) { + if record := c.messageUses[original]; record != nil { + if existing := c.messageUses[copy]; existing != nil && existing != record { + panic("protobuf copy is connected to two message declarations") + } + if c.messageUses[copy] == nil { + c.messageUses[copy] = record + record.uses = append(record.uses, copy) + } + } + if record := c.unionUses[original]; record != nil { + if existing := c.unionUses[copy]; existing != nil && existing != record { + panic("protobuf copy is connected to two oneof declarations") + } + if c.unionUses[copy] == nil { + c.unionUses[copy] = record + record.uses = append(record.uses, copy) + } + } + }) +} + +// bindCopiedValidationUses records the validation function for each matching +// part of a copied protobuf value. +func (c *protobufPackageCatalog) bindCopiedValidationUses(original, copy *expr.AttributeExpr, side validateKind) { + walkProtobufCopy(original, copy, func(original, copy *expr.AttributeExpr) { + record := c.validationUses[protobufValidationUse{attribute: original, side: side}] + if record != nil { + c.bindValidationUse(copy, side, record) + } + }) +} + +// findUnion returns the oneof with the same parent message, field name, and +// branch types. +func (c *protobufPackageCatalog) findUnion(owner *protobufMessageRecord, fieldName string, attribute *expr.AttributeExpr) *protobufUnionRecord { + for _, record := range c.unions { + if record.owner == owner && record.fieldName == fieldName && + sameProtobufWireAttribute(record.attribute, attribute, make(map[protobufAttributePair]struct{})) { + return record + } + } + return nil +} + +// collectValidationRecursive records one function per message, set of rules, +// and client or server package, then visits the nested messages it may call. +func (c *protobufPackageCatalog) collectValidationRecursive(attribute *expr.AttributeExpr, side validateKind, source protobufValidationSource, targetName, contextName string, seen map[*protobufValidationRecord]struct{}) { + switch actual := attribute.Type.(type) { + case expr.UserType: + if expr.IsPrimitive(actual) { + return + } + policy := codegen.GoLayoutPolicy{IgnoreRequired: true} + if !codegen.NeedsValidation(userTypeAttribute(actual), policy) { + return + } + message := c.messageRecord(attribute) + if message == nil { + panic(fmt.Sprintf("no protobuf declaration collected for validation type %q", actual.Name())) + } + record := c.findValidation(message, attribute, side) + if record == nil { + record = &protobufValidationRecord{ + message: message, + attribute: attribute, + source: source, + side: side, + targetName: targetName, + contextName: contextName, + } + c.validators = append(c.validators, record) + } else if source.compare(record.source) < 0 { + record.source = source + record.targetName = targetName + record.contextName = contextName + } + c.bindValidationUse(attribute, side, record) + if _, ok := seen[record]; ok { + return + } + seen[record] = struct{}{} + c.collectValidationRecursive(userTypeAttribute(actual), side, source, targetName, contextName, seen) + case *expr.Object: + for _, named := range *actual { + c.collectValidationRecursive(named.Attribute, side, source.child(named.Name), codegen.Goify(named.Name, false), named.Name, seen) + } + case *expr.Array: + c.collectValidationRecursive(actual.ElemType, side, source.child("element"), "elem", "elem", seen) + case *expr.Map: + c.collectValidationRecursive(actual.KeyType, side, source.child("key"), "key", "key", seen) + c.collectValidationRecursive(actual.ElemType, side, source.child("value"), "val", "val", seen) + case *expr.Union: + for _, named := range actual.Values { + c.collectValidationRecursive(named.Attribute, side, source.child(named.Name), codegen.Goify(named.Name, false), named.Name, seen) + } + } +} + +// findMessage returns the message written for the same source type, requested +// name, and protobuf fields. +func (c *protobufPackageCatalog) findMessage(identity protobufMessageIdentity) *protobufMessageRecord { + for _, record := range c.messages { + if sameProtobufMessageIdentity(record.identity, identity) { + return record + } + } + return nil +} + +// findValidation returns the existing function that checks the same message +// rules in the same client or server package. +func (c *protobufPackageCatalog) findValidation(declaration *protobufMessageRecord, attribute *expr.AttributeExpr, side validateKind) *protobufValidationRecord { + for _, record := range c.validators { + if record.message == declaration && record.side == side && + sameProtobufValidationAttribute(record.attribute, attribute, make(map[protobufAttributePair]struct{})) { + return record + } + } + return nil +} + +// validationRecord returns the validation function called for one copied +// message value. +func (c *protobufPackageCatalog) validationRecord(attribute *expr.AttributeExpr, side validateKind) *protobufValidationRecord { + return c.validationUses[protobufValidationUse{attribute: attribute, side: side}] +} + +// bindValidationUse records the function that checks one protobuf value in a +// generated client or server package. +func (c *protobufPackageCatalog) bindValidationUse(attribute *expr.AttributeExpr, side validateKind, record *protobufValidationRecord) { + key := protobufValidationUse{attribute: attribute, side: side} + if existing := c.validationUses[key]; existing != nil && existing != record { + panic("protobuf value is connected to two validation functions") + } + if c.validationUses[key] == nil { + c.validationUses[key] = record + record.uses = append(record.uses, attribute) + } +} + +// walkProtobufCopy visits matching parts of an original protobuf value and its +// copy. Recursive user types are visited once. +func walkProtobufCopy(original, copy *expr.AttributeExpr, visit func(*expr.AttributeExpr, *expr.AttributeExpr)) { + seen := make(map[protobufAttributePair]struct{}) + var walk func(*expr.AttributeExpr, *expr.AttributeExpr) + walk = func(original, copy *expr.AttributeExpr) { + pair := protobufAttributePair{left: original, right: copy} + if _, ok := seen[pair]; ok { + return + } + seen[pair] = struct{}{} + visit(original, copy) + switch originalType := original.Type.(type) { + case expr.UserType: + copyType := copy.Type.(expr.UserType) + walk(userTypeAttribute(originalType), userTypeAttribute(copyType)) + case *expr.Object: + copyType := copy.Type.(*expr.Object) + for index, field := range *originalType { + walk(field.Attribute, (*copyType)[index].Attribute) + } + case *expr.Array: + walk(originalType.ElemType, copy.Type.(*expr.Array).ElemType) + case *expr.Map: + copyType := copy.Type.(*expr.Map) + walk(originalType.KeyType, copyType.KeyType) + walk(originalType.ElemType, copyType.ElemType) + case *expr.Union: + copyType := copy.Type.(*expr.Union) + for index, branch := range originalType.Values { + walk(branch.Attribute, copyType.Values[index].Attribute) + } + } + } + walk(original, copy) +} + +// messageData returns only messages whose names and template data are complete. +func (c *protobufPackageCatalog) messageData() []*service.UserTypeData { + data := make([]*service.UserTypeData, 0, len(c.messages)) + for _, record := range c.messages { + if record.data != nil { + data = append(data, record.data) + } + } + return data +} + +// protoMessageData returns message records with the names written to the +// protobuf source file. +func (c *protobufPackageCatalog) protoMessageData() []*service.UserTypeData { + data := make([]*service.UserTypeData, 0, len(c.messages)) + for _, record := range c.messages { + if record.data == nil { + continue + } + message := *record.data + message.Name = record.protoName + message.VarName = record.protoName + data = append(data, &message) + } + return data +} + +// validationData returns only validation functions that contain checks. +func (c *protobufPackageCatalog) validationData() []*ValidationData { + data := make([]*ValidationData, 0, len(c.validators)) + for _, record := range c.validators { + if record.data != nil { + data = append(data, record.data) + } + } + return data +} + +// child returns the same endpoint value at one nested field or collection +// part. +func (s protobufValidationSource) child(name string) protobufValidationSource { + if s.path == "" { + s.path = name + } else { + s.path += "." + name + } + return s +} + +// compare orders endpoint values and their nested fields the same way even when +// the design lists them in a different order. +func (s protobufValidationSource) compare(other protobufValidationSource) int { + if result := strings.Compare(s.api, other.api); result != 0 { + return result + } + if result := strings.Compare(s.service, other.service); result != 0 { + return result + } + if result := strings.Compare(s.method, other.method); result != 0 { + return result + } + if result := strings.Compare(s.error, other.error); result != 0 { + return result + } + if s.role < other.role { + return -1 + } + if s.role > other.role { + return 1 + } + return strings.Compare(s.path, other.path) +} + +// protobufMessageIdentityFor records the source type, requested name, and +// protobuf fields that decide whether two uses share one message. +func protobufMessageIdentityFor(attribute *expr.AttributeExpr, source protobufMessageSource) (protobufMessageIdentity, error) { + userType := attribute.Type.(expr.UserType) + if source.origin == nil && source.synthetic.role == 0 { + source.origin = userType.Origin() + } + preferred := codegen.ProtobufName(userType.Name()) + explicit := false + names := attribute.Meta["struct:name:proto"] + if len(names) == 0 { + names = userType.Attribute().Meta["struct:name:proto"] + } + if len(names) > 0 { + if !protobufExactNamePattern.MatchString(names[0]) { + return protobufMessageIdentity{}, fmt.Errorf("protobuf message name %q from struct:name:proto is not a valid protobuf identifier", names[0]) + } + preferred = names[0] + explicit = true + } + return protobufMessageIdentity{ + source: source, + preferredName: preferred, + explicitName: explicit, + userType: userType, + attribute: userTypeAttribute(userType), + }, nil +} + +// sameProtobufMessageIdentity reports whether two requests have the same source +// type, requested name, and protobuf fields. It does not compare generated Go +// names. +func sameProtobufMessageIdentity(left, right protobufMessageIdentity) bool { + if left.source != right.source || left.preferredName != right.preferredName || left.explicitName != right.explicitName { + return false + } + return sameProtobufWireAttribute(left.attribute, right.attribute, make(map[protobufAttributePair]struct{})) +} + +// sameProtobufWireAttribute reports whether two values produce the same fields +// and rules in a .proto message. +func sameProtobufWireAttribute(left, right *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) bool { + if left == right { + return true + } + if left == nil || right == nil || left.Description != right.Description { + return false + } + pair := protobufAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if !sameProtobufMeta(left.Meta, right.Meta) { + return false + } + if leftObject, rightObject := expr.AsObject(left.Type), expr.AsObject(right.Type); leftObject != nil && rightObject != nil { + for _, named := range *leftObject { + if expr.IsPrimitive(named.Attribute.Type) && left.IsRequired(named.Name) != right.IsRequired(named.Name) { + return false + } + } + } + return sameProtobufWireType(left.Type, right.Type, seen) +} + +// sameProtobufWireType reports whether two types produce the same protobuf type, +// including ordered fields, choices, arrays, maps, and nested messages. +func sameProtobufWireType(left, right expr.DataType, seen map[protobufAttributePair]struct{}) bool { + if left.Kind() != right.Kind() { + return false + } + switch left := left.(type) { + case expr.Primitive: + return left == right.(expr.Primitive) + case expr.UserType: + right := right.(expr.UserType) + sameSource := left.Origin() == right.Origin() + bothSyntheticWrappers := len(left.Attribute().Meta[wrappedAttrMeta]) > 0 && + len(right.Attribute().Meta[wrappedAttrMeta]) > 0 + return (sameSource || bothSyntheticWrappers) && sameProtobufWireAttribute(left.Attribute(), right.Attribute(), seen) + case *expr.Object: + right := right.(*expr.Object) + if len(*left) != len(*right) { + return false + } + for index, named := range *left { + other := (*right)[index] + if named.Name != other.Name || !sameProtobufWireAttribute(named.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Array: + right := right.(*expr.Array) + return sameProtobufWireAttribute(left.ElemType, right.ElemType, seen) + case *expr.Map: + right := right.(*expr.Map) + return sameProtobufWireAttribute(left.KeyType, right.KeyType, seen) && sameProtobufWireAttribute(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.(*expr.Union) + if left.TypeName != right.TypeName || left.TypeKey != right.TypeKey || left.ValueKey != right.ValueKey || len(left.Values) != len(right.Values) { + return false + } + for index, named := range left.Values { + other := right.Values[index] + if named.Name != other.Name || !sameProtobufWireAttribute(named.Attribute, other.Attribute, seen) { + return false + } + } + return true + default: + panic(fmt.Sprintf("unknown protobuf wire type %T", left)) + } +} + +// sameProtobufValidationAttribute compares the source types and validation +// rules without comparing the protobuf message name. +func sameProtobufValidationAttribute(left, right *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) bool { + if left == right { + return true + } + if left == nil || right == nil || !reflect.DeepEqual(left.Validation, right.Validation) || + !reflect.DeepEqual(left.DefaultValue, right.DefaultValue) || + !reflect.DeepEqual(left.Meta["struct:field:type"], right.Meta["struct:field:type"]) { + return false + } + pair := protobufAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if left.Type.Kind() != right.Type.Kind() { + return false + } + switch left := left.Type.(type) { + case expr.Primitive: + return left == right.Type.(expr.Primitive) + case expr.UserType: + right := right.Type.(expr.UserType) + return left.Origin() == right.Origin() && sameProtobufValidationAttribute(left.Attribute(), right.Attribute(), seen) + case *expr.Object: + right := right.Type.(*expr.Object) + if len(*left) != len(*right) { + return false + } + for index, named := range *left { + other := (*right)[index] + if named.Name != other.Name || !sameProtobufValidationAttribute(named.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Array: + right := right.Type.(*expr.Array) + return left.NonNullableElems == right.NonNullableElems && sameProtobufValidationAttribute(left.ElemType, right.ElemType, seen) + case *expr.Map: + right := right.Type.(*expr.Map) + return sameProtobufValidationAttribute(left.KeyType, right.KeyType, seen) && sameProtobufValidationAttribute(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.Type.(*expr.Union) + if len(left.Values) != len(right.Values) { + return false + } + for index, named := range left.Values { + other := right.Values[index] + if named.Name != other.Name || !sameProtobufValidationAttribute(named.Attribute, other.Attribute, seen) { + return false + } + } + return true + default: + panic(fmt.Sprintf("unknown protobuf validation type %T", left)) + } +} + +// sameProtobufMeta compares metadata that changes protobuf field numbers, +// external types, explicit names, wrapper layout, or JSON names. +func sameProtobufMeta(left, right expr.MetaExpr) bool { + for _, name := range []string{ + "rpc:tag", + "struct:field:proto", + "struct:name:proto", + "proto:tag:json", + wrappedAttrMeta, + } { + if !reflect.DeepEqual(left[name], right[name]) { + return false + } + } + return true +} diff --git a/grpc/codegen/protobuf_descriptor_plan_test.go b/grpc/codegen/protobuf_descriptor_plan_test.go new file mode 100644 index 0000000000..9c128f5c85 --- /dev/null +++ b/grpc/codegen/protobuf_descriptor_plan_test.go @@ -0,0 +1,349 @@ +// This file checks that a linked gRPC plan uses the Go names produced by the +// supported protobuf tools in every generated client and server file. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestPlanUsesNamesFromSupportedProtobufTools checks both method orders because +// changing source order must not change the Go declarations chosen for a file. +func TestPlanUsesNamesFromSupportedProtobufTools(t *testing.T) { + for _, reverse := range []bool{false, true} { + name := "unary-first" + if reverse { + name = "stream-first" + } + t.Run(name, func(t *testing.T) { + moduleDir, protoPath, generatedGo := renderProtobufDescriptorPlan(t, reverse) + descriptor := describeGeneratedProto(t, protoPath) + rules, err := newProtocNameRules(protocNameVersionGo1_36GRPC1_6) + require.NoError(t, err) + names, err := rules.file(descriptor) + require.NoError(t, err) + + serviceDescriptor := descriptor.GetService()[0] + messageDescriptor := messageWithOneof(t, descriptor, "result2_kind") + oneofDescriptor := messageDescriptor.GetOneofDecl()[0] + resetDescriptor := fieldNamed(t, messageDescriptor, "reset") + apiURLDescriptor := fieldNamed(t, messageDescriptor, "api_url") + dns2Descriptor := fieldNamed(t, messageDescriptor, "dns2_server") + stringDescriptor := fieldNamed(t, messageDescriptor, "string_") + oneofStringDescriptor := fieldNamed(t, messageDescriptor, "string_2") + require.NotEqual(t, stringDescriptor.GetName(), oneofStringDescriptor.GetName()) + require.NotNil(t, oneofStringDescriptor.OneofIndex) + packageName := descriptor.GetPackage() + serviceName := packageName + "." + serviceDescriptor.GetName() + messageName := packageName + "." + messageDescriptor.GetName() + outerStringName, ok := names.lookup(messageName+"."+stringDescriptor.GetName(), protocFieldName) + require.True(t, ok) + require.Equal(t, "String_", outerStringName) + oneofStringName, ok := names.lookup(messageName+"."+oneofStringDescriptor.GetName(), protocFieldName) + require.True(t, ok) + require.Equal(t, "String_2", oneofStringName) + oneofStringWrapper, ok := names.lookup(messageName+"."+oneofStringDescriptor.GetName(), protocOneofWrapperName) + require.True(t, ok) + require.Equal(t, messageDescriptor.GetName()+"_String_2", oneofStringWrapper) + methodNames := make(map[string]string, len(serviceDescriptor.GetMethod())) + for _, method := range serviceDescriptor.GetMethod() { + methodNames[method.GetName()] = serviceName + "." + method.GetName() + } + + checks := []struct { + descriptor string + role protocNameRole + }{ + {messageName, protocMessageName}, + {messageName + "." + apiURLDescriptor.GetName(), protocFieldName}, + {messageName + "." + dns2Descriptor.GetName(), protocFieldName}, + {messageName + "." + stringDescriptor.GetName(), protocFieldName}, + {messageName + "." + oneofStringDescriptor.GetName(), protocFieldName}, + {messageName + "." + oneofStringDescriptor.GetName(), protocOneofWrapperName}, + {messageName + "." + oneofDescriptor.GetName(), protocOneofFieldName}, + {messageName + "." + resetDescriptor.GetName(), protocOneofWrapperName}, + {serviceName, protocServiceClientName}, + {serviceName, protocServiceServerName}, + {methodNames["GetUrl2"], protocMethodName}, + {methodNames["SyncX509"], protocMethodName}, + {methodNames["SyncX509"], protocMethodClientStreamName}, + {methodNames["SyncX509"], protocMethodServerStreamName}, + } + declarations := declaredGoNames(t, + strings.TrimSuffix(protoPath, ".proto")+".pb.go", + strings.TrimSuffix(protoPath, ".proto")+"_grpc.pb.go", + ) + for _, check := range checks { + name, ok := names.lookup(check.descriptor, check.role) + require.True(t, ok, "%s was not recorded for %s", check.role, check.descriptor) + require.Contains(t, declarations, name, "the protobuf tools did not declare %s", name) + require.True(t, strings.Contains(generatedGo, name), "Goa did not use %s", name) + } + + protoSource, err := os.ReadFile(protoPath) + require.NoError(t, err) + require.Contains(t, string(protoSource), "message lower_snake_message {") + require.Contains(t, string(protoSource), "lower_snake_message lower = 2;") + require.Contains(t, string(protoSource), "message "+messageDescriptor.GetName()+" {") + require.Contains(t, string(protoSource), "oneof result2_kind {") + require.Contains(t, string(protoSource), "service "+serviceDescriptor.GetName()+" {") + require.Contains(t, string(protoSource), "rpc GetUrl2 (") + require.Contains(t, string(protoSource), "rpc CafRead (") + require.Contains(t, string(protoSource), "rpc SyncX509 (stream ") + compileProtobufDescriptorModule(t, moduleDir) + }) + } +} + +// TestPlanWritesLegalFieldAndOneofNames checks names that would make protoc +// reject the complete generated file if Goa wrote them unchanged. +func TestPlanWritesLegalFieldAndOneofNames(t *testing.T) { + _, protoPath, _ := renderProtobufDescriptorPlan(t, false) + protoSource, err := os.ReadFile(protoPath) + require.NoError(t, err) + + require.Contains(t, string(protoSource), "message LeadingDigit {") + require.Contains(t, string(protoSource), "optional string _123_field = 1;") + require.Contains(t, string(protoSource), "message UnicodeName {") + require.Contains(t, string(protoSource), "optional string caf_field = 1;") + require.Contains(t, string(protoSource), "oneof foo_bar_oneof {") + require.Contains(t, string(protoSource), "optional string foo_bar = 3;") + require.Contains(t, string(protoSource), "message CollisionReverse {") + require.Equal(t, 2, strings.Count(string(protoSource), "oneof foo_bar_oneof {")) +} + +// TestPlanRejectsIllegalExactProtobufName checks that an exact metadata name +// fails before Goa writes a protobuf file that protoc cannot parse. +func TestPlanRejectsIllegalExactProtobufName(t *testing.T) { + root := RunGRPCDSL(t, func() { + message := dsl.Type("Message", func() { + dsl.Meta("struct:name:proto", "123_message") + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("invalid", func() { + dsl.Method("read", func() { + dsl.Payload(message) + dsl.Result(message) + dsl.GRPC(func() {}) + }) + }) + }) + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + + _, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.EqualError(t, err, `service "invalid" protobuf message name "123_message" from struct:name:proto is not a valid protobuf identifier`) +} + +// renderProtobufDescriptorPlan writes one linked plan and returns the temporary +// module, its protobuf source file, and the Goa client and server source. +func renderProtobufDescriptorPlan(t *testing.T, reverse bool) (string, string, string) { + t.Helper() + root := RunGRPCDSL(t, protobufDescriptorPlanDSL(reverse)) + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlans[0].Link()) + require.NoError(t, plans[0].Link()) + + files, err := service.Files(servicePlans...) + require.NoError(t, err) + files = append(files, plans[0].ServerFiles()...) + files = append(files, plans[0].ClientFiles()...) + files = append(files, plans[0].ServerTypeFiles()...) + files = append(files, plans[0].ClientTypeFiles()...) + files = append(files, plans[0].ProtoFiles()...) + + moduleDir := t.TempDir() + writeProtobufDescriptorModule(t, moduleDir) + var protoPath string + var generated strings.Builder + for _, file := range files { + path, err := file.Render(moduleDir) + require.NoError(t, err) + if filepath.Ext(path) == ".proto" { + protoPath = path + continue + } + if strings.Contains(filepath.ToSlash(path), "/grpc/") { + source, err := os.ReadFile(path) + require.NoError(t, err) + generated.Write(source) + } + } + require.NotEmpty(t, protoPath) + return moduleDir, protoPath, generated.String() +} + +// protobufDescriptorPlanDSL returns a design that uses one object in unary and +// streaming messages and places its preferred protobuf name beside a service +// declaration with the same Go name. +func protobufDescriptorPlanDSL(reverse bool) func() { + return func() { + leadingDigit := dsl.Type("LeadingDigit", func() { + dsl.Field(1, "123_field", dsl.String, func() { + dsl.Meta("struct:field:name", "LeadingField") + }) + }) + unicodeName := dsl.Type("UnicodeName", func() { + dsl.Field(1, "caféField", dsl.String, func() { + dsl.Meta("struct:field:name", "CafeField") + }) + }) + collision := dsl.Type("Collision", func() { + dsl.OneOf("fooBar", func() { + dsl.Field(2, "text", dsl.String) + }) + dsl.Field(3, "foo_bar", dsl.String, func() { + dsl.Meta("struct:field:name", "OtherFooBar") + }) + }) + collisionReverse := dsl.Type("CollisionReverse", func() { + dsl.Field(1, "foo_bar", dsl.String, func() { + dsl.Meta("struct:field:name", "OtherFooBar") + }) + dsl.OneOf("fooBar", func() { + dsl.Field(2, "text", dsl.String) + }) + }) + shared := dsl.Type("Api2HttpServiceClient", func() { + dsl.Field(1, "api_url", dsl.String) + dsl.Field(2, "dns_2_server", dsl.String) + dsl.Field(5, "string", dsl.String) + dsl.OneOf("result_2_kind", func() { + dsl.Field(3, "http_2xx", dsl.String) + dsl.Field(4, "reset", dsl.String) + dsl.Field(6, "string", dsl.String) + }) + }) + lowerSnake := dsl.Type("LowerSnake", func() { + dsl.Meta("struct:name:proto", "lower_snake_message") + dsl.Field(1, "value", dsl.String) + }) + envelope := dsl.Type("API2Envelope", func() { + dsl.Field(1, "client", shared) + dsl.Field(2, "lower", lowerSnake) + dsl.Field(3, "leading", leadingDigit) + dsl.Field(4, "collision", collision) + dsl.Field(5, "unicode", unicodeName) + dsl.Field(6, "collisionReverse", collisionReverse) + }) + unary := func() { + dsl.Method("get_url2", func() { + dsl.Payload(envelope) + dsl.Result(envelope) + dsl.GRPC(func() {}) + }) + } + stream := func() { + dsl.Method("sync_x509", func() { + dsl.StreamingPayload(envelope) + dsl.StreamingResult(envelope) + dsl.GRPC(func() {}) + }) + } + unicodeMethod := func() { + dsl.Method("café_read", func() { + dsl.Payload(envelope) + dsl.Result(envelope) + dsl.GRPC(func() {}) + }) + } + dsl.Service("api2_http_service", func() { + if reverse { + stream() + unary() + unicodeMethod() + return + } + unary() + stream() + unicodeMethod() + }) + } +} + +// describeGeneratedProto asks protoc for the names and fields written in one +// generated protobuf source file. +func describeGeneratedProto(t *testing.T, protoPath string) *descriptorpb.FileDescriptorProto { + t.Helper() + descriptorPath := filepath.Join(t.TempDir(), "descriptor.pb") + args := defaultProtocCmd[1:len(defaultProtocCmd):len(defaultProtocCmd)] + args = append(args, + "--proto_path", filepath.Dir(protoPath), + "--descriptor_set_out", descriptorPath, + protoPath, + ) + output, err := exec.Command(defaultProtocCmd[0], args...).CombinedOutput() + require.NoError(t, err, string(output)) + encoded, err := os.ReadFile(descriptorPath) + require.NoError(t, err) + set := &descriptorpb.FileDescriptorSet{} + require.NoError(t, proto.Unmarshal(encoded, set)) + require.Len(t, set.File, 1) + return set.File[0] +} + +// messageWithOneof returns the message that declares the named choice field. +func messageWithOneof(t *testing.T, file *descriptorpb.FileDescriptorProto, name string) *descriptorpb.DescriptorProto { + t.Helper() + for _, message := range file.GetMessageType() { + for _, oneof := range message.GetOneofDecl() { + if oneof.GetName() == name { + return message + } + } + } + t.Fatalf("protobuf source did not declare oneof %q", name) + return nil +} + +// fieldNamed returns the field with the requested protobuf source name. +func fieldNamed(t *testing.T, message *descriptorpb.DescriptorProto, name string) *descriptorpb.FieldDescriptorProto { + t.Helper() + for _, field := range message.GetField() { + if field.GetName() == name { + return field + } + } + t.Fatalf("protobuf message %q did not declare field %q", message.GetName(), name) + return nil +} + +// writeProtobufDescriptorModule writes a module that imports this Goa checkout. +func writeProtobufDescriptorModule(t *testing.T, directory string) { + t.Helper() + command := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "goa.design/goa/v3") + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + goaDirectory := strings.TrimSpace(string(output)) + require.NotEmpty(t, goaDirectory) + module := "module generated.local\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaDirectory) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) +} + +// compileProtobufDescriptorModule compiles every package written by the plan. +func compileProtobufDescriptorModule(t *testing.T, directory string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) +} diff --git a/grpc/codegen/protobuf_plan.go b/grpc/codegen/protobuf_plan.go new file mode 100644 index 0000000000..a17feccb88 --- /dev/null +++ b/grpc/codegen/protobuf_plan.go @@ -0,0 +1,921 @@ +// This file asks the supported protobuf tools for every Go name that Goa must +// reference, then stores those names before any generated file is built. +package codegen + +import ( + "cmp" + "fmt" + "sort" + "strconv" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // protobufServicePlan stores one service's copied messages and the Go names + // that protoc and its Go plugins produce for them. + protobufServicePlan struct { + expression *expr.GRPCServiceExpr + catalog *protobufPackageCatalog + messages []*protobufEndpointMessages + protoPackage string + serviceName string + fileIndex int + order protobufServiceOrder + methods map[*expr.GRPCEndpointExpr]string + names map[protocNameKey]*codegen.NameDeclaration + localNames map[protocNameKey]string + fields map[*expr.AttributeExpr]protocNameKey + sourceFields map[*expr.AttributeExpr]string + sourceOneofs map[*expr.AttributeExpr]string + wrappers map[*expr.AttributeExpr]protocNameKey + oneofs map[*expr.AttributeExpr]protocNameKey + } + + // protobufNameGroup holds one name written to a .proto file and every Go name + // generated from it. If a Go name is already used, Goa adds the same number + // to the .proto name and asks the tools for the complete set again. + protobufNameGroup struct { + preferred string + name string + suffix int + message *protobufMessageRecord + method *expr.GRPCEndpointExpr + service bool + } + + // protobufServiceOrder holds the two names used to place services in a + // stable order. + protobufServiceOrder struct { + service string + api string + } +) + +// planProtobufServices records every protobuf declaration in the generated Go +// package that will contain it. +func planProtobufServices(generation *codegen.Generation, roots []*Plan) error { + groups := make(map[string][]*protobufServicePlan) + for _, rootPlan := range roots { + for _, service := range rootPlan.expressions { + pathName := rootPlan.packages[service].pathName + packagePath := generation.GenPkg() + "/grpc/" + pathName + "/" + pbPkgName + catalog := newProtobufPackageCatalog("") + messages, err := collectProtobufPackage(service, catalog) + if err != nil { + return fmt.Errorf("service %q %w", service.Name(), err) + } + plan := &protobufServicePlan{ + expression: service, + catalog: catalog, + messages: messages, + protoPackage: pkgName(service, pathName), + order: protobufServiceOrder{ + service: service.Name(), + api: rootPlan.root.API.Name, + }, + methods: make(map[*expr.GRPCEndpointExpr]string, len(service.GRPCEndpoints)), + names: make(map[protocNameKey]*codegen.NameDeclaration), + localNames: make(map[protocNameKey]string), + fields: make(map[*expr.AttributeExpr]protocNameKey), + sourceFields: make(map[*expr.AttributeExpr]string), + sourceOneofs: make(map[*expr.AttributeExpr]string), + wrappers: make(map[*expr.AttributeExpr]protocNameKey), + oneofs: make(map[*expr.AttributeExpr]protocNameKey), + } + catalog.plan = plan + rootPlan.protobuf[service] = plan + groups[packagePath] = append(groups[packagePath], plan) + } + } + for packagePath, group := range groups { + pkg, err := generation.ClaimPackage(packagePath) + if err != nil { + return err + } + sort.Slice(group, func(i, j int) bool { + if group[i].order.service != group[j].order.service { + return group[i].order.service < group[j].order.service + } + return group[i].order.api < group[j].order.api + }) + for index := 1; index < len(group); index++ { + if group[index-1].order == group[index].order { + return fmt.Errorf( + "generated protobuf package %q has two services named %q in API %q", + packagePath, + group[index].order.service, + group[index].order.api, + ) + } + } + for _, plan := range group[1:] { + if plan.protoPackage != group[0].protoPackage { + return fmt.Errorf("generated package %q cannot contain protobuf packages %q and %q", packagePath, group[0].protoPackage, plan.protoPackage) + } + } + used := make(map[string]struct{}) + for index, plan := range group { + plan.fileIndex = index + 1 + if err := plan.chooseNames(pkg, used); err != nil { + return fmt.Errorf("plan protobuf names for service %q: %w", plan.expression.Name(), err) + } + } + } + return nil +} + +// name returns one Go name produced by the supported protobuf tools. +func (p *protobufServicePlan) name(descriptor string, role protocNameRole) string { + key := protocNameKey{descriptor: descriptor, role: role} + if declaration := p.names[key]; declaration != nil { + return declaration.Name() + } + name, ok := p.localNames[key] + if !ok { + panic(fmt.Sprintf("protobuf Go name %s for %q was not planned", role, descriptor)) + } + return name +} + +// fieldName returns the Go field name produced for one copied protobuf field. +func (p *protobufServicePlan) fieldName(attribute *expr.AttributeExpr) (string, bool) { + key, ok := p.fields[attribute] + if !ok { + return "", false + } + return p.name(key.descriptor, key.role), true +} + +// sourceFieldName returns the field name written to the protobuf file. +func (p *protobufServicePlan) sourceFieldName(attribute *expr.AttributeExpr) string { + name, ok := p.sourceFields[attribute] + if !ok { + panic("protobuf source field was not planned") + } + return name +} + +// sourceOneofName returns the oneof name written to the protobuf file. +func (p *protobufServicePlan) sourceOneofName(attribute *expr.AttributeExpr) string { + name, ok := p.sourceOneofs[attribute] + if !ok { + panic("protobuf source oneof was not planned") + } + return name +} + +// wrapperName returns the Go wrapper type for one branch in one parent +// message. +func (p *protobufServicePlan) wrapperName(attribute *expr.AttributeExpr) (string, bool) { + key, ok := p.wrappers[attribute] + if !ok { + return "", false + } + return p.name(key.descriptor, key.role), true +} + +// oneofInterfaceName returns the Go interface produced for one oneof. +func (p *protobufServicePlan) oneofInterfaceName(record *protobufUnionRecord) string { + key, ok := p.oneofs[record.attribute] + if !ok { + panic("protobuf oneof interface name was not planned") + } + return p.name(key.descriptor, key.role) +} + +// bindAttributeCopy records every message, choice, field, and wrapper name for +// the matching parts of a copied protobuf value. +func (p *protobufServicePlan) bindAttributeCopy(original, copy *expr.AttributeExpr) { + p.catalog.bindCopiedMessageUses(original, copy) + walkProtobufCopy(original, copy, func(original, copy *expr.AttributeExpr) { + if key, ok := p.fields[original]; ok { + p.fields[copy] = key + } + if name, ok := p.sourceFields[original]; ok { + p.sourceFields[copy] = name + } + if name, ok := p.sourceOneofs[original]; ok { + p.sourceOneofs[copy] = name + } + if key, ok := p.wrappers[original]; ok { + p.wrappers[copy] = key + } + if key, ok := p.oneofs[original]; ok { + p.oneofs[copy] = key + } + }) +} + +// chooseNames tries numbered protobuf names until every generated Go name is +// unique in the package. +func (p *protobufServicePlan) chooseNames(pkg *codegen.GeneratedPackage, used map[string]struct{}) error { + groups, err := p.nameGroups() + if err != nil { + return err + } + p.assignInitialNames(groups) + rules, err := newProtocNameRules(protocNameVersionGo1_36GRPC1_6) + if err != nil { + return err + } + for attempts := 0; attempts < 1000; attempts++ { + descriptor, owners, err := p.namingDescriptor(groups) + if err != nil { + return err + } + generated, err := rules.file(descriptor) + if err != nil { + return err + } + colliding := collidingProtobufGroup(generated, owners, groups, used) + if colliding == nil { + if err := p.declareNames(pkg, generated); err != nil { + return err + } + for key, name := range generated.values { + if _, packageName := protocPackageNameKind(key.role); packageName { + used[name] = struct{}{} + } + } + return nil + } + colliding.name = nextAvailableProtobufName(colliding, groups) + } + return fmt.Errorf("could not choose unique protobuf Go names") +} + +// nameGroups puts the service and methods before messages. This keeps the +// requested service and method names when a message would generate the same Go +// name. +func (p *protobufServicePlan) nameGroups() ([]*protobufNameGroup, error) { + service := &protobufNameGroup{ + preferred: codegen.ProtobufName(p.expression.Name()), + service: true, + } + methods := make([]*protobufNameGroup, 0, len(p.expression.GRPCEndpoints)) + for _, endpoint := range p.expression.GRPCEndpoints { + methods = append(methods, &protobufNameGroup{ + preferred: codegen.ProtobufName(endpoint.Name()), + method: endpoint, + }) + } + sort.Slice(methods, func(i, j int) bool { + return compareProtobufEndpointSource(methods[i].method, methods[j].method) < 0 + }) + messages := make([]*protobufNameGroup, 0, len(p.catalog.messages)) + for _, message := range p.catalog.messages { + messages = append(messages, &protobufNameGroup{ + preferred: message.identity.preferredName, + message: message, + }) + } + if err := sortProtobufMessageGroups(messages); err != nil { + return nil, err + } + groups := []*protobufNameGroup{service} + groups = append(groups, methods...) + return append(groups, messages...), nil +} + +// assignInitialNames makes message and service names unique in the file. Method +// names use a separate list because protobuf allows the same method name in a +// different service. +func (p *protobufServicePlan) assignInitialNames(groups []*protobufNameGroup) { + packageNames := make(map[string]struct{}) + methodNames := make(map[string]struct{}) + for _, group := range groups { + used := packageNames + if group.method != nil { + used = methodNames + } + assignProtobufGroupName(group, used) + } +} + +// namingDescriptor builds a small protobuf file containing only declarations +// that can change generated Go names. A field's type cannot change its Go name, +// so these temporary fields all use string. +func (p *protobufServicePlan) namingDescriptor(groups []*protobufNameGroup) (*descriptorpb.FileDescriptorProto, map[protocNameKey]*protobufNameGroup, error) { + owners := make(map[protocNameKey]*protobufNameGroup) + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("goa_names.proto"), + Package: proto.String(p.protoPackage), + Syntax: proto.String(ProtoVersion), + Options: &descriptorpb.FileOptions{GoPackage: proto.String("/" + p.protoPackage + "pb")}, + } + var serviceGroup *protobufNameGroup + for _, group := range groups { + switch { + case group.service: + serviceGroup = group + p.serviceName = group.name + case group.method != nil: + p.methods[group.method] = group.name + case group.message != nil: + group.message.protoName = group.name + message, keys := p.messageDescriptor(group.message) + for _, use := range group.message.uses { + useType := use.Type.(expr.UserType) + p.bindAttributeCopy(group.message.identity.attribute, userTypeAttribute(useType)) + } + file.MessageType = append(file.MessageType, message) + for _, key := range keys { + owners[key] = group + } + } + } + service, keys, err := p.serviceDescriptor(serviceGroup, groups) + if err != nil { + return nil, nil, err + } + file.Service = []*descriptorpb.ServiceDescriptorProto{service} + for _, key := range keys { + owner := serviceGroup + for _, group := range groups { + if group.method != nil && key.descriptor == p.serviceFullName()+"."+group.name { + owner = group + break + } + } + owners[key] = owner + } + return file, owners, nil +} + +// messageDescriptor records one message's fields and oneofs for protogen. +func (p *protobufServicePlan) messageDescriptor(record *protobufMessageRecord) (*descriptorpb.DescriptorProto, []protocNameKey) { + message := &descriptorpb.DescriptorProto{Name: proto.String(record.protoName)} + fullName := p.protoPackage + "." + record.protoName + keys := []protocNameKey{{descriptor: fullName, role: protocMessageName}} + usedFieldNames := make(map[string]struct{}) + fieldNames := make(map[*expr.AttributeExpr]string) + fieldNumber := int32(1) + attribute := record.identity.attribute + if userType, ok := attribute.Type.(expr.UserType); ok { + attribute = userType.Attribute() + } + object := expr.AsObject(attribute.Type) + if object == nil { + if union, ok := attribute.Type.(*expr.Union); ok { + for _, branch := range union.Values { + fieldNames[branch.Attribute] = uniqueProtobufSourceName(protobufSourceFieldName(branch.Name), usedFieldNames) + } + oneofName := uniqueProtobufOneofSourceName(union.Name(), usedFieldNames) + p.addOneofDescriptor(message, fullName, oneofName, attribute, union, fieldNames, &fieldNumber, &keys) + } + return message, keys + } + for _, named := range *object { + if union, ok := named.Attribute.Type.(*expr.Union); ok { + for _, branch := range union.Values { + fieldNames[branch.Attribute] = uniqueProtobufSourceName(protobufSourceFieldName(branch.Name), usedFieldNames) + } + continue + } + fieldNames[named.Attribute] = uniqueProtobufSourceName(protobufSourceFieldName(named.Name), usedFieldNames) + } + oneofNames := make(map[*expr.AttributeExpr]string) + for _, named := range *object { + if _, ok := named.Attribute.Type.(*expr.Union); ok { + oneofNames[named.Attribute] = uniqueProtobufOneofSourceName(named.Name, usedFieldNames) + } + } + for _, named := range *object { + if union, ok := named.Attribute.Type.(*expr.Union); ok { + p.addOneofDescriptor(message, fullName, oneofNames[named.Attribute], named.Attribute, union, fieldNames, &fieldNumber, &keys) + continue + } + fieldName := fieldNames[named.Attribute] + message.Field = append(message.Field, namingField(fieldName, fieldNumber, nil)) + fieldNumber++ + key := protocNameKey{descriptor: fullName + "." + fieldName, role: protocFieldName} + p.fields[named.Attribute] = key + p.sourceFields[named.Attribute] = fieldName + keys = append(keys, key) + } + return message, keys +} + +// addOneofDescriptor records one oneof and each branch field after every source +// name in the message has been selected. +func (p *protobufServicePlan) addOneofDescriptor(message *descriptorpb.DescriptorProto, messageName, oneofName string, attribute *expr.AttributeExpr, union *expr.Union, fieldNames map[*expr.AttributeExpr]string, fieldNumber *int32, keys *[]protocNameKey) { + index := int32(len(message.OneofDecl)) + message.OneofDecl = append(message.OneofDecl, &descriptorpb.OneofDescriptorProto{Name: proto.String(oneofName)}) + fieldKey := protocNameKey{descriptor: messageName + "." + oneofName, role: protocOneofFieldName} + interfaceKey := protocNameKey{descriptor: messageName + "." + oneofName, role: protocOneofInterfaceName} + p.fields[attribute] = fieldKey + p.sourceOneofs[attribute] = oneofName + p.oneofs[attribute] = interfaceKey + *keys = append(*keys, fieldKey, interfaceKey) + for _, branch := range union.Values { + name := fieldNames[branch.Attribute] + message.Field = append(message.Field, namingField(name, *fieldNumber, &index)) + *fieldNumber++ + fieldKey := protocNameKey{descriptor: messageName + "." + name, role: protocFieldName} + wrapperKey := protocNameKey{descriptor: messageName + "." + name, role: protocOneofWrapperName} + p.fields[branch.Attribute] = fieldKey + p.sourceFields[branch.Attribute] = name + p.wrappers[branch.Attribute] = wrapperKey + *keys = append(*keys, fieldKey, wrapperKey) + } +} + +// serviceDescriptor records the service methods and their stream directions. +func (p *protobufServicePlan) serviceDescriptor(serviceGroup *protobufNameGroup, groups []*protobufNameGroup) (*descriptorpb.ServiceDescriptorProto, []protocNameKey, error) { + service := &descriptorpb.ServiceDescriptorProto{Name: proto.String(serviceGroup.name)} + serviceName := p.protoPackage + "." + serviceGroup.name + keys := serviceNameKeys(serviceName) + for _, group := range groups { + if group.method == nil { + continue + } + endpoint := group.method + index := slicesIndexEndpoint(p.expression.GRPCEndpoints, endpoint) + if index < 0 { + return nil, nil, fmt.Errorf("method %q is not part of service %q", endpoint.Name(), p.expression.Name()) + } + messages := p.messages[index] + request := p.catalog.messageUses[messages.request] + if messages.requestEnvelope != nil { + request = p.catalog.messageUses[messages.requestEnvelope] + } else if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + request = p.catalog.messageUses[messages.streamingRequest] + } + response := p.catalog.messageUses[messages.response] + if request == nil || response == nil { + return nil, nil, fmt.Errorf("method %q has no protobuf request or response message", endpoint.Name()) + } + method := &descriptorpb.MethodDescriptorProto{ + Name: proto.String(group.name), + InputType: proto.String("." + p.protoPackage + "." + request.protoName), + OutputType: proto.String("." + p.protoPackage + "." + response.protoName), + ClientStreaming: proto.Bool(endpoint.MethodExpr.IsPayloadStreaming()), + ServerStreaming: proto.Bool(endpoint.MethodExpr.IsResultStreaming()), + } + service.Method = append(service.Method, method) + keys = append(keys, methodNameKeys(serviceName+"."+group.name, endpoint.MethodExpr.IsStreaming())...) + } + return service, keys, nil +} + +// declareNames stores package-level Go names with the package that writes them. +// It stores field and method names directly because they cannot collide with +// names outside their message or service. +func (p *protobufServicePlan) declareNames(pkg *codegen.GeneratedPackage, generated *protocNames) error { + for key, name := range generated.values { + kind, packageName := protocPackageNameKind(key.role) + if !packageName { + p.localNames[key] = name + continue + } + declaration := codegen.NewExactName(kind, name) + if err := pkg.DeclareName(declaration); err != nil { + return err + } + p.names[key] = declaration + } + for _, record := range p.catalog.messages { + key := protocNameKey{ + descriptor: p.protoPackage + "." + record.protoName, + role: protocMessageName, + } + record.plannedName = generated.values[key] + record.declaration = p.names[key] + if record.declaration == nil { + return fmt.Errorf("protobuf message %q has no generated Go declaration", record.protoName) + } + } + return nil +} + +// collidingProtobufGroup returns the first group that would generate a Go name +// already used in the package. +func collidingProtobufGroup(names *protocNames, owners map[protocNameKey]*protobufNameGroup, groups []*protobufNameGroup, occupied map[string]struct{}) *protobufNameGroup { + byGroup := make(map[*protobufNameGroup][]string) + for key, name := range names.values { + _, packageName := protocPackageNameKind(key.role) + if packageName { + byGroup[owners[key]] = append(byGroup[owners[key]], name) + } + } + used := make(map[string]struct{}, len(occupied)) + for name := range occupied { + used[name] = struct{}{} + } + for _, group := range groups { + for _, name := range byGroup[group] { + if _, ok := used[name]; ok { + return group + } + } + for _, name := range byGroup[group] { + used[name] = struct{}{} + } + } + return nil +} + +// protocPackageNameKind identifies names declared at package level. +func protocPackageNameKind(role protocNameRole) (codegen.PackageNameKind, bool) { + switch role { + case protocMessageName, protocEnumName, protocOneofInterfaceName, protocOneofWrapperName, + protocServiceClientName, protocServiceClientStructName, protocServiceServerName, + protocServiceUnimplementedServerName, protocServiceUnsafeServerName, + protocMethodClientStreamName, protocMethodServerStreamName: + return codegen.NameType, true + case protocServiceClientConstructorName, protocServiceRegisterName, protocMethodHandlerName: + return codegen.NameFunction, true + case protocMethodFullName: + return codegen.NameConstant, true + case protocServiceDescriptorName: + return codegen.NameVariable, true + default: + return 0, false + } +} + +// sortProtobufMessageGroups puts messages in the same order for every input +// order. It reports separate declarations when their source, requested name, +// and protobuf fields cannot choose which one comes first. +func sortProtobufMessageGroups(groups []*protobufNameGroup) error { + sort.Slice(groups, func(i, j int) bool { + return compareProtobufMessageIdentity(groups[i].message.identity, groups[j].message.identity) < 0 + }) + for index := 1; index < len(groups); index++ { + left := groups[index-1].message.identity + right := groups[index].message.identity + if compareProtobufMessageIdentity(left, right) == 0 && !sameProtobufMessageIdentity(left, right) { + return fmt.Errorf("protobuf messages named %q have the same source, name, and fields but come from separate declarations", left.preferredName) + } + } + return nil +} + +// compareProtobufMessageIdentity compares the source, requested name, and +// protobuf fields that decide whether two values use one message. +func compareProtobufMessageIdentity(left, right protobufMessageIdentity) int { + if order := compareProtobufMessageSource(left.source, right.source); order != 0 { + return order + } + if order := cmp.Compare(left.preferredName, right.preferredName); order != 0 { + return order + } + if order := compareBool(left.explicitName, right.explicitName); order != 0 { + return order + } + return compareProtobufWireAttribute(left.attribute, right.attribute, make(map[protobufAttributePair]struct{})) +} + +// compareProtobufMessageSource compares the design declaration or method value +// that produced a protobuf message. +func compareProtobufMessageSource(left, right protobufMessageSource) int { + leftAuthored := left.origin != nil + rightAuthored := right.origin != nil + if order := compareBool(leftAuthored, rightAuthored); order != 0 { + return order + } + if leftAuthored { + if left.origin == right.origin { + return 0 + } + if order := cmp.Compare(left.origin.ID(), right.origin.ID()); order != 0 { + return order + } + return cmp.Compare(left.origin.Name(), right.origin.Name()) + } + if order := cmp.Compare(left.synthetic.role, right.synthetic.role); order != 0 { + return order + } + if order := compareProtobufEndpointSource(left.synthetic.endpoint, right.synthetic.endpoint); order != 0 { + return order + } + return compareProtobufErrorSource(left.synthetic.error, right.synthetic.error) +} + +// compareProtobufEndpointSource compares the service and method names that +// produced a generated request or response message. +func compareProtobufEndpointSource(left, right *expr.GRPCEndpointExpr) int { + if order := compareBool(left != nil, right != nil); order != 0 { + return order + } + if left == nil || left == right { + return 0 + } + if order := cmp.Compare(left.Service.Name(), right.Service.Name()); order != 0 { + return order + } + return cmp.Compare(left.Name(), right.Name()) +} + +// compareProtobufErrorSource compares the error names that produced generated +// error messages. +func compareProtobufErrorSource(left, right *expr.GRPCErrorExpr) int { + if order := compareBool(left != nil, right != nil); order != 0 { + return order + } + if left == nil || left == right { + return 0 + } + return cmp.Compare(left.Name, right.Name) +} + +// compareProtobufWireAttribute orders values by the description, protobuf +// settings, required primitive fields, and type written to the .proto file. +func compareProtobufWireAttribute(left, right *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) int { + if left == right { + return 0 + } + if order := compareBool(left != nil, right != nil); order != 0 { + return order + } + if order := cmp.Compare(left.Description, right.Description); order != 0 { + return order + } + pair := protobufAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return 0 + } + seen[pair] = struct{}{} + if order := compareProtobufMeta(left.Meta, right.Meta); order != 0 { + return order + } + if order := compareProtobufWireType(left.Type, right.Type, seen); order != 0 { + return order + } + leftObject, rightObject := expr.AsObject(left.Type), expr.AsObject(right.Type) + if leftObject == nil || rightObject == nil { + return 0 + } + for _, field := range *leftObject { + if !expr.IsPrimitive(field.Attribute.Type) { + continue + } + if order := compareBool(left.IsRequired(field.Name), right.IsRequired(field.Name)); order != 0 { + return order + } + } + return 0 +} + +// compareProtobufWireType orders values by the protobuf type and each nested +// field in the order Goa writes them. +func compareProtobufWireType(left, right expr.DataType, seen map[protobufAttributePair]struct{}) int { + if order := cmp.Compare(left.Kind(), right.Kind()); order != 0 { + return order + } + switch left := left.(type) { + case expr.Primitive: + return cmp.Compare(left, right.(expr.Primitive)) + case expr.UserType: + right := right.(expr.UserType) + leftWrapped := len(left.Attribute().Meta[wrappedAttrMeta]) > 0 + rightWrapped := len(right.Attribute().Meta[wrappedAttrMeta]) > 0 + if order := compareBool(leftWrapped, rightWrapped); order != 0 { + return order + } + if !leftWrapped && left.Origin() != right.Origin() { + if order := cmp.Compare(left.Origin().ID(), right.Origin().ID()); order != 0 { + return order + } + if order := cmp.Compare(left.Origin().Name(), right.Origin().Name()); order != 0 { + return order + } + } + return compareProtobufWireAttribute(left.Attribute(), right.Attribute(), seen) + case *expr.Object: + right := right.(*expr.Object) + if order := cmp.Compare(len(*left), len(*right)); order != 0 { + return order + } + for index, field := range *left { + other := (*right)[index] + if order := cmp.Compare(field.Name, other.Name); order != 0 { + return order + } + if order := compareProtobufWireAttribute(field.Attribute, other.Attribute, seen); order != 0 { + return order + } + } + return 0 + case *expr.Array: + return compareProtobufWireAttribute(left.ElemType, right.(*expr.Array).ElemType, seen) + case *expr.Map: + right := right.(*expr.Map) + if order := compareProtobufWireAttribute(left.KeyType, right.KeyType, seen); order != 0 { + return order + } + return compareProtobufWireAttribute(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.(*expr.Union) + for _, order := range []int{ + cmp.Compare(left.TypeName, right.TypeName), + cmp.Compare(left.TypeKey, right.TypeKey), + cmp.Compare(left.ValueKey, right.ValueKey), + cmp.Compare(len(left.Values), len(right.Values)), + } { + if order != 0 { + return order + } + } + for index, branch := range left.Values { + other := right.Values[index] + if order := cmp.Compare(branch.Name, other.Name); order != 0 { + return order + } + if order := compareProtobufWireAttribute(branch.Attribute, other.Attribute, seen); order != 0 { + return order + } + } + return 0 + default: + panic(fmt.Sprintf("unknown protobuf wire type %T", left)) + } +} + +// compareProtobufMeta compares settings that change protobuf field numbers, +// names, external types, or generated wrappers. +func compareProtobufMeta(left, right expr.MetaExpr) int { + for _, name := range []string{ + "rpc:tag", + "struct:field:proto", + "struct:name:proto", + "proto:tag:json", + wrappedAttrMeta, + } { + if order := compareStringList(left[name], right[name]); order != 0 { + return order + } + } + return 0 +} + +// compareStringList compares both presence and contents because a missing list +// and an empty list are separate metadata values. +func compareStringList(left, right []string) int { + if order := compareBool(left != nil, right != nil); order != 0 { + return order + } + if order := cmp.Compare(len(left), len(right)); order != 0 { + return order + } + for index, value := range left { + if order := cmp.Compare(value, right[index]); order != 0 { + return order + } + } + return 0 +} + +// compareBool orders false before true. +func compareBool(left, right bool) int { + switch { + case left == right: + return 0 + case left: + return 1 + default: + return -1 + } +} + +// uniqueProtobufSourceName adds a number until the name is unused in the set. +func uniqueProtobufSourceName(preferred string, used map[string]struct{}) string { + for index := 1; ; index++ { + name := preferred + if index > 1 { + name += strconv.Itoa(index) + } + if _, ok := used[name]; ok { + continue + } + used[name] = struct{}{} + return name + } +} + +// assignProtobufGroupName stores both the selected name and its numeric suffix +// so later collision retries do not need to parse the name. +func assignProtobufGroupName(group *protobufNameGroup, used map[string]struct{}) { + for suffix := 1; ; suffix++ { + name := group.preferred + if suffix > 1 { + name += strconv.Itoa(suffix) + } + if _, ok := used[name]; ok { + continue + } + used[name] = struct{}{} + group.name = name + group.suffix = suffix + return + } +} + +// nextAvailableProtobufName returns the next numbered name that is not used by +// another message, service, or method in the same protobuf file. +func nextAvailableProtobufName(changed *protobufNameGroup, groups []*protobufNameGroup) string { + preferred := changed.preferred + index := max(changed.suffix, 1) + for { + index++ + candidate := preferred + strconv.Itoa(index) + available := true + for _, group := range groups { + if group == changed || group.name != candidate { + continue + } + if (group.method == nil) == (changed.method == nil) { + available = false + break + } + } + if available { + changed.suffix = index + return candidate + } + } +} + +// namingField creates one field whose type is sufficient for Go name planning. +func namingField(name string, number int32, oneof *int32) *descriptorpb.FieldDescriptorProto { + field := &descriptorpb.FieldDescriptorProto{ + Name: proto.String(name), + Number: &number, + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + } + if oneof != nil { + field.OneofIndex = oneof + } + return field +} + +// protobufSourceFieldName returns the field spelling written to .proto. +func protobufSourceFieldName(name string) string { + return codegen.ProtobufFieldName(name) +} + +// uniqueProtobufOneofSourceName adds a suffix until the oneof name differs +// from every field, branch, and earlier oneof in the message. +func uniqueProtobufOneofSourceName(fieldName string, used map[string]struct{}) string { + name := codegen.ProtobufFieldName(fieldName) + for { + if _, ok := used[name]; !ok { + used[name] = struct{}{} + return name + } + name += "_oneof" + } +} + +// serviceNameKeys returns every package name written for one service. +func serviceNameKeys(descriptor string) []protocNameKey { + roles := []protocNameRole{ + protocServiceClientName, + protocServiceClientStructName, + protocServiceClientConstructorName, + protocServiceServerName, + protocServiceUnimplementedServerName, + protocServiceUnsafeServerName, + protocServiceRegisterName, + protocServiceDescriptorName, + } + keys := make([]protocNameKey, len(roles)) + for index, role := range roles { + keys[index] = protocNameKey{descriptor: descriptor, role: role} + } + return keys +} + +// methodNameKeys returns every name written for one method. +func methodNameKeys(descriptor string, streaming bool) []protocNameKey { + roles := []protocNameRole{protocMethodName, protocMethodFullName, protocMethodHandlerName} + if streaming { + roles = append(roles, protocMethodClientStreamName, protocMethodServerStreamName) + } + keys := make([]protocNameKey, len(roles)) + for index, role := range roles { + keys[index] = protocNameKey{descriptor: descriptor, role: role} + } + return keys +} + +// serviceFullName returns the current service descriptor name. +func (p *protobufServicePlan) serviceFullName() string { + return p.protoPackage + "." + p.serviceName +} + +// slicesIndexEndpoint returns endpoint's position in endpoints. +func slicesIndexEndpoint(endpoints []*expr.GRPCEndpointExpr, endpoint *expr.GRPCEndpointExpr) int { + for index, candidate := range endpoints { + if candidate == endpoint { + return index + } + } + return -1 +} diff --git a/grpc/codegen/protobuf_plan_order_test.go b/grpc/codegen/protobuf_plan_order_test.go new file mode 100644 index 0000000000..18137523c5 --- /dev/null +++ b/grpc/codegen/protobuf_plan_order_test.go @@ -0,0 +1,216 @@ +// This file checks that protobuf service ordering has one clear result for +// every generation input. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestSortProtobufMessageGroupsUsesWireDetails checks that field numbers and +// explicit protobuf names give messages the same order when the input is reversed. +func TestSortProtobufMessageGroupsUsesWireDetails(t *testing.T) { + tests := []struct { + name string + groups func() []*protobufNameGroup + value func(*protobufNameGroup) string + want []string + }{ + { + name: "field numbers", + groups: func() []*protobufNameGroup { + source := protobufOrderUserType() + return []*protobufNameGroup{ + protobufOrderGroup(source, "Message", false, "2"), + protobufOrderGroup(source, "Message", false, "1"), + } + }, + value: func(group *protobufNameGroup) string { + field := expr.AsObject(group.message.identity.attribute.Type).Attribute("value") + return field.Meta["rpc:tag"][0] + }, + want: []string{"1", "2"}, + }, + { + name: "explicit names", + groups: func() []*protobufNameGroup { + source := protobufOrderUserType() + return []*protobufNameGroup{ + protobufOrderGroup(source, "Zulu", true, "1"), + protobufOrderGroup(source, "Alpha", true, "1"), + } + }, + value: func(group *protobufNameGroup) string { + return group.message.identity.preferredName + }, + want: []string{"Alpha", "Zulu"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + forward := test.groups() + reverse := test.groups() + reverse[0], reverse[1] = reverse[1], reverse[0] + + require.NoError(t, sortProtobufMessageGroups(forward)) + require.NoError(t, sortProtobufMessageGroups(reverse)) + require.Equal(t, test.want, protobufOrderValues(forward, test.value)) + require.Equal(t, test.want, protobufOrderValues(reverse, test.value)) + }) + } +} + +// TestSortProtobufMessageGroupsRejectsEqualOrder checks that two separate +// declarations cannot receive names according to their input order. +func TestSortProtobufMessageGroupsRejectsEqualOrder(t *testing.T) { + left := protobufOrderUserType() + right := protobufOrderUserType() + groups := []*protobufNameGroup{ + protobufOrderGroup(left, "Message", false, "1"), + protobufOrderGroup(right, "Message", false, "1"), + } + + err := sortProtobufMessageGroups(groups) + require.EqualError(t, err, `protobuf messages named "Message" have the same source, name, and fields but come from separate declarations`) +} + +// TestCompareProtobufMessageOrderReversesWithInputs checks objects whose fields +// and required lists appear in opposite orders. +func TestCompareProtobufMessageOrderReversesWithInputs(t *testing.T) { + source := protobufOrderUserType() + left := protobufRequiredOrderGroup(source, "a", "b") + right := protobufRequiredOrderGroup(source, "b", "a") + + forward := compareProtobufMessageIdentity(left.message.identity, right.message.identity) + backward := compareProtobufMessageIdentity(right.message.identity, left.message.identity) + require.NotZero(t, forward) + require.Equal(t, -forward, backward) + + forwardGroups := []*protobufNameGroup{left, right} + reverseGroups := []*protobufNameGroup{right, left} + require.NoError(t, sortProtobufMessageGroups(forwardGroups)) + require.NoError(t, sortProtobufMessageGroups(reverseGroups)) + require.Equal(t, []string{"a", "b"}, protobufFirstFieldNames(forwardGroups)) + require.Equal(t, []string{"a", "b"}, protobufFirstFieldNames(reverseGroups)) +} + +// TestNextAvailableProtobufNameUsesRetainedSuffix checks that collision retries +// do not parse the generated name Goa stored for the group. +func TestNextAvailableProtobufNameUsesRetainedSuffix(t *testing.T) { + group := &protobufNameGroup{preferred: "Message", name: "unrelated"} + occupied := &protobufNameGroup{preferred: "Message", name: "Message2"} + + next := nextAvailableProtobufName(group, []*protobufNameGroup{group, occupied}) + require.Equal(t, "Message3", next) +} + +// TestPlanProtobufServicesRejectsEqualOrder checks that two separate services +// cannot receive names according to their input order. +func TestPlanProtobufServicesRejectsEqualOrder(t *testing.T) { + roots := grpcPlanRoots(t, "Shared", "Shared") + roots[0].API.Name = "Shared API" + roots[1].API.Name = "Shared API" + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{roots[0], roots[1]}) + require.NoError(t, err) + plans := []*Plan{ + { + root: roots[0], + expressions: roots[0].API.GRPC.Services, + protobuf: make(map[*expr.GRPCServiceExpr]*protobufServicePlan), + packages: map[*expr.GRPCServiceExpr]*grpcServicePackage{ + roots[0].API.GRPC.Services[0]: {pathName: "shared"}, + }, + }, + { + root: roots[1], + expressions: roots[1].API.GRPC.Services, + protobuf: make(map[*expr.GRPCServiceExpr]*protobufServicePlan), + packages: map[*expr.GRPCServiceExpr]*grpcServicePackage{ + roots[1].API.GRPC.Services[0]: {pathName: "shared"}, + }, + }, + } + + err = planProtobufServices(generation, plans) + require.EqualError(t, err, `generated protobuf package "generated.local/gen/grpc/shared/pb" has two services named "Shared" in API "Shared API"`) +} + +// protobufOrderUserType creates one separate source declaration for order +// tests. +func protobufOrderUserType() *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + TypeName: "Message", + UID: "message", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + { + Name: "value", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"rpc:tag": {"1"}}, + }, + }, + }}, + } +} + +// protobufOrderGroup creates one message with the requested name and field +// number. +func protobufOrderGroup(source expr.UserType, name string, explicit bool, tag string) *protobufNameGroup { + attribute := expr.DupAtt(source.Attribute()) + expr.AsObject(attribute.Type).Attribute("value").Meta["rpc:tag"] = []string{tag} + return &protobufNameGroup{ + preferred: name, + message: &protobufMessageRecord{identity: protobufMessageIdentity{ + source: protobufMessageSource{origin: source}, + preferredName: name, + explicitName: explicit, + userType: source, + attribute: attribute, + }}, + } +} + +// protobufRequiredOrderGroup creates one message whose first field is required. +func protobufRequiredOrderGroup(source expr.UserType, first, second string) *protobufNameGroup { + attribute := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: first, Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: second, Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + Validation: &expr.ValidationExpr{Required: []string{first}}, + } + return &protobufNameGroup{ + preferred: "Message", + message: &protobufMessageRecord{identity: protobufMessageIdentity{ + source: protobufMessageSource{origin: source}, + preferredName: "Message", + userType: source, + attribute: attribute, + }}, + } +} + +// protobufOrderValues reads the fact checked by one order test from every +// message. +func protobufOrderValues(groups []*protobufNameGroup, value func(*protobufNameGroup) string) []string { + values := make([]string, len(groups)) + for index, group := range groups { + values[index] = value(group) + } + return values +} + +// protobufFirstFieldNames returns the first field from each ordered message. +func protobufFirstFieldNames(groups []*protobufNameGroup) []string { + names := make([]string, len(groups)) + for index, group := range groups { + names[index] = (*expr.AsObject(group.message.identity.attribute.Type))[0].Name + } + return names +} diff --git a/grpc/codegen/protobuf_test.go b/grpc/codegen/protobuf_test.go index fe8dae4b86..e4c79b8815 100644 --- a/grpc/codegen/protobuf_test.go +++ b/grpc/codegen/protobuf_test.go @@ -1,3 +1,5 @@ +// This file verifies protobuf wire shaping, naming, JSON options, wrappers, +// and recursion follow generated-package declaration ownership. package codegen import ( @@ -11,52 +13,6 @@ import ( "goa.design/goa/v3/expr" ) -func TestProtobufify(t *testing.T) { - cases := []struct { - Name string - String string - FirstUpper bool - Acronym bool - Expected string - }{{ - "AllLower", "lower", false, false, "lower", - }, { - "AllLowerFirstUpper", "lower", true, false, "Lower", - }, { - "AllUpper", "UPPER", false, false, "uPPER", - }, { - "AllUpperFirstUpper", "UPPER", true, false, "UPPER", - }, { - "StartUpperThenLower", "Upper", false, false, "upper", - }, { - "StartUpperThenLowerFirstUpper", "Upper", true, false, "Upper", - }, { - "StartsWithUnderscore", "_foo", false, false, "foo", - }, { - "EndsWithUnderscore", "foo_", false, false, "foo", - }, { - "ContainsUnderscore", "foo_bar", false, false, "fooBar", - }, { - "StartsWithDigits", "123foo", false, false, "123Foo", - }, { - "EndsWithDigits", "foo123", false, false, "foo123", - }, { - "ContainsDigits", "foo123bar", false, false, "foo123Bar", - }, { - "ContainsIgnoredAcronym", "foo_jwt", false, false, "fooJwt", - }, { - "ContainsAcronym", "foo_jwt", false, true, "fooJWT", - }} - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - got := protoBufify(c.String, c.FirstUpper, c.Acronym) - if got != c.Expected { - t.Errorf("got %q, expected %q", got, c.Expected) - } - }) - } -} - func TestProtoNativeType(t *testing.T) { cases := []struct { Name string @@ -184,6 +140,27 @@ func TestHasAnyType(t *testing.T) { } } +// TestHasAnyTypeStopsAtRecursiveTypes checks that a cycle does not hide an Any +// field elsewhere in the same type. +func TestHasAnyTypeStopsAtRecursiveTypes(t *testing.T) { + recursive := &expr.UserTypeExpr{TypeName: "Recursive", UID: "recursive"} + recursive.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{ + Name: "next", + Attribute: &expr.AttributeExpr{Type: recursive}, + }, + &expr.NamedAttributeExpr{ + Name: "data", + Attribute: &expr.AttributeExpr{Type: expr.Any}, + }, + }} + require.True(t, hasAnyType(recursive.Attribute())) + + object := expr.AsObject(recursive.Attribute().Type) + *object = (*object)[:1] + require.False(t, hasAnyType(recursive.Attribute())) +} + func TestProtoBufMessageDefJSONNameOption(t *testing.T) { attr := &expr.AttributeExpr{ Type: &expr.Object{ @@ -272,8 +249,11 @@ func TestMakeProtoBufMessageMarksWrappers(t *testing.T) { }} for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - sd := &ServiceData{Name: "Service", Scope: codegen.NewNameScope()} - att := makeProtoBufMessage(&expr.AttributeExpr{Type: c.Type()}, "Message", sd) + att := makeProtoBufMessage( + &expr.AttributeExpr{Type: c.Type()}, + "Message", + testGRPCMessageExampleIdentity(c.Name), + ) require.True(t, isWrappedAttr(att), "expected message to be marked as a wrapper") field := unwrapAttr(att) assert.Equal(t, c.FieldKind, field.Type.Kind(), "unexpected wrapped field kind") @@ -287,6 +267,147 @@ func TestMakeProtoBufMessageMarksWrappers(t *testing.T) { } } +func TestMakeProtoBufMessageDistinguishesEqualUIDOrigins(t *testing.T) { + first := protobufArrayTraversalType("First", "shared") + second := protobufArrayTraversalType("Second", "shared") + body := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + + message := makeProtoBufMessage(body, "Request", testGRPCMessageExampleIdentity("equal-UID-origins")) + object := expr.AsObject(message.Type.(expr.UserType).Attribute().Type) + wireFirst := object.Attribute("first").Type.(expr.UserType) + wireSecond := object.Attribute("second").Type.(expr.UserType) + require.True(t, isWrappedAttr(&expr.AttributeExpr{Type: wireFirst})) + require.True(t, isWrappedAttr(&expr.AttributeExpr{Type: wireSecond})) +} + +func TestMakeProtoBufMessageDistinguishesNormalizedMethodNames(t *testing.T) { + service := &expr.ServiceExpr{Name: "Values"} + dashedMethod := &expr.MethodExpr{Name: "foo-bar", Service: service} + underscoreMethod := &expr.MethodExpr{Name: "foo_bar", Service: service} + dashedOwner := expr.GRPCRequestMessageExampleIdentity(dashedMethod) + underscoreOwner := expr.GRPCRequestMessageExampleIdentity(underscoreMethod) + dashed := makeProtoBufMessage( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "dashed", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + "FooBarRequest", + dashedOwner, + ) + underscore := makeProtoBufMessage( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "underscore", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + "FooBarRequest", + underscoreOwner, + ) + require.NotEqual(t, dashed.Type.(expr.UserType).ID(), underscore.Type.(expr.UserType).ID()) + + cases := []struct { + name string + first *expr.AttributeExpr + firstOwner expr.ExampleIdentity + firstField string + second *expr.AttributeExpr + secondOwner expr.ExampleIdentity + secondField string + }{ + { + name: "dashed then underscore", + first: dashed, + firstOwner: dashedOwner, + firstField: "dashed", + second: underscore, + secondOwner: underscoreOwner, + secondField: "underscore", + }, + { + name: "underscore then dashed", + first: underscore, + firstOwner: underscoreOwner, + firstField: "underscore", + second: dashed, + secondOwner: dashedOwner, + secondField: "dashed", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) + first := test.first.Example(generator.At(test.firstOwner)).(map[string]any) + second := test.second.Example(generator.At(test.secondOwner)).(map[string]any) + + require.Contains(t, first, test.firstField) + require.NotContains(t, first, test.secondField) + require.Contains(t, second, test.secondField) + require.NotContains(t, second, test.firstField) + }) + } +} + +func TestMakeProtoBufMessageSharesAuthoredCollectionWrapperIdentity(t *testing.T) { + arrayAlias := &expr.UserTypeExpr{ + TypeName: "Strings", + UID: "strings", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}, + } + mapAlias := &expr.UserTypeExpr{ + TypeName: "Labels", + UID: "labels", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: expr.Int}, + }}, + } + owner := testGRPCMessageExampleIdentity("shared-collection-aliases") + build := func(fields []string) *expr.AttributeExpr { + attributes := make(expr.Object, len(fields)) + for index, name := range fields { + typ := expr.UserType(arrayAlias) + if name == "map_a" || name == "map_b" { + typ = mapAlias + } + attributes[index] = &expr.NamedAttributeExpr{ + Name: name, + Attribute: &expr.AttributeExpr{Type: typ}, + } + } + return makeProtoBufMessage( + &expr.AttributeExpr{Type: &attributes}, + "SharedCollectionsRequest", + owner, + ) + } + forward := build([]string{"array_a", "map_a", "array_b", "map_b"}) + reverse := build([]string{"map_b", "array_b", "map_a", "array_a"}) + example := func(message *expr.AttributeExpr) map[string]any { + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) + return message.Example(generator.At(owner)).(map[string]any) + } + + forwardExample := example(forward) + reverseExample := example(reverse) + require.Equal(t, forwardExample, reverseExample) + require.Equal(t, forwardExample["array_a"], forwardExample["array_b"]) + require.Equal(t, forwardExample["map_a"], forwardExample["map_b"]) +} + +// protobufArrayTraversalType builds an authored array declaration that protobuf +// conversion must wrap in a message. +func protobufArrayTraversalType(name, uid string) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}, + TypeName: name, + UID: uid, + } +} + func TestUnwrapAttrPanicsOnNonWrapper(t *testing.T) { cases := []struct { Name string diff --git a/grpc/codegen/protobuf_tools.go b/grpc/codegen/protobuf_tools.go new file mode 100644 index 0000000000..50174e0c9f --- /dev/null +++ b/grpc/codegen/protobuf_tools.go @@ -0,0 +1,176 @@ +// This file chooses the protobuf compiler and Go plugins before any generated +// file is built. +package codegen + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + + "goa.design/goa/v3/expr" +) + +type ( + // protobufToolPlan stores the compiler, plugins, and include paths for one + // service. + protobufToolPlan struct { + command []string + includes []string + goPlugin string + goGRPCPlugin string + } + + // protobufToolResolver lets tests provide fixed program paths and versions + // without changing PATH or running real programs. + protobufToolResolver struct { + resolve func(string) (string, error) + version func(string) (string, error) + } +) + +const ( + protocGenGoName = "protoc-gen-go" + protocGenGoGRPCName = "protoc-gen-go-grpc" + protocGenGoVersion = "protoc-gen-go v1.36.12" + protocGenGoGRPCVersion = "protoc-gen-go-grpc 1.6.2" +) + +// planProtobufTools copies the compiler settings for every gRPC service and +// uses one checked pair of Go plugins for one call to NewPlans. +func planProtobufTools(inputs []PlanInput, resolver protobufToolResolver) (map[*expr.GRPCServiceExpr]*protobufToolPlan, error) { + goPlugin, err := resolveProtobufPlugin(resolver, protocGenGoName, protocGenGoVersion) + if err != nil { + return nil, err + } + goGRPCPlugin, err := resolveProtobufPlugin(resolver, protocGenGoGRPCName, protocGenGoGRPCVersion) + if err != nil { + return nil, err + } + + plans := make(map[*expr.GRPCServiceExpr]*protobufToolPlan) + compilers := make(map[string]string) + for _, input := range inputs { + for _, service := range input.Root.API.GRPC.Services { + command := protobufCompilerCommand(input.Root, service) + if len(command) == 0 { + return nil, fmt.Errorf(`Meta("protoc:cmd"): must be given arguments`) + } + if plugin := replacedGoPlugin(command[1:]); plugin != "" { + return nil, fmt.Errorf(`Meta("protoc:cmd") cannot replace required plugin %q`, plugin) + } + compiler := compilers[command[0]] + if compiler == "" { + compiler, err = resolver.resolve(command[0]) + if err != nil { + return nil, fmt.Errorf("resolve protobuf compiler %q: %w", command[0], err) + } + compilers[command[0]] = compiler + } + command[0] = compiler + includes := append([]string{}, service.ServiceExpr.Meta["protoc:include"]...) + includes = append(includes, input.Root.API.Meta["protoc:include"]...) + plans[service] = &protobufToolPlan{ + command: command, + includes: includes, + goPlugin: goPlugin, + goGRPCPlugin: goGRPCPlugin, + } + } + } + return plans, nil +} + +// systemProtobufTools uses the programs available to Goa. +func systemProtobufTools() protobufToolResolver { + return protobufToolResolver{ + resolve: resolveProtobufExecutable, + version: protobufExecutableVersion, + } +} + +// protobufCompilerCommand copies the command selected by the service or API. +func protobufCompilerCommand(root *expr.RootExpr, service *expr.GRPCServiceExpr) []string { + command := defaultProtocCmd + if configured, ok := root.API.Meta["protoc:cmd"]; ok { + command = configured + } + if configured, ok := service.ServiceExpr.Meta["protoc:cmd"]; ok { + command = configured + } + return append([]string{}, command...) +} + +// resolveProtobufPlugin finds one required plugin and checks its version. +func resolveProtobufPlugin(resolver protobufToolResolver, name, wantVersion string) (string, error) { + path, err := resolver.resolve(name) + if err != nil { + return "", fmt.Errorf("resolve protobuf plugin %s: %w", name, err) + } + version, err := resolver.version(path) + if err != nil { + return "", fmt.Errorf("read protobuf plugin %s version: %w", name, err) + } + if !protobufPluginVersionMatches(name, version, wantVersion) { + return "", fmt.Errorf("protobuf plugin %s reports version %s, want %s", name, version, wantVersion) + } + return path, nil +} + +// protobufPluginVersionMatches accepts the program name printed on Unix and +// the same name with the executable suffix printed on Windows. +func protobufPluginVersionMatches(name, version, wantVersion string) bool { + windowsVersion := name + ".exe" + strings.TrimPrefix(wantVersion, name) + return version == wantVersion || version == windowsVersion +} + +// resolveProtobufExecutable returns an absolute path for one executable. +func resolveProtobufExecutable(name string) (string, error) { + path, err := exec.LookPath(name) + if err != nil { + return "", err + } + path, err = filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("make executable path absolute: %w", err) + } + return path, nil +} + +// protobufExecutableVersion returns the single version line printed by an +// executable. +func protobufExecutableVersion(path string) (string, error) { + output, err := exec.Command(path, "--version").CombinedOutput() + if err != nil { + return "", fmt.Errorf("run %s --version: %w: %s", path, err, output) + } + return strings.TrimSpace(string(output)), nil +} + +// replacedGoPlugin returns a required plugin name when the flags try to +// replace it. +func replacedGoPlugin(arguments []string) string { + for index := 0; index < len(arguments); index++ { + argument := arguments[index] + var plugin string + switch { + case argument == "--plugin" && index+1 < len(arguments): + index++ + plugin = arguments[index] + case strings.HasPrefix(argument, "--plugin="): + plugin = strings.TrimPrefix(argument, "--plugin=") + default: + continue + } + var name string + if configuredName, _, ok := strings.Cut(plugin, "="); ok { + name = configuredName + } else { + name = filepath.Base(plugin) + } + if name == protocGenGoName || name == protocGenGoGRPCName { + return name + } + } + return "" +} diff --git a/grpc/codegen/protobuf_tools_test.go b/grpc/codegen/protobuf_tools_test.go new file mode 100644 index 0000000000..f7d538b638 --- /dev/null +++ b/grpc/codegen/protobuf_tools_test.go @@ -0,0 +1,231 @@ +// This file checks that gRPC planning fixes the protobuf commands before any +// generated file is rendered or compiled. +package codegen + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestNewPlansRetainsProtobufCommands checks that later design and PATH changes +// cannot replace the compiler, plugins, or include paths chosen by NewPlans. +func TestNewPlansRetainsProtobufCommands(t *testing.T) { + root := grpcPlanRoots(t, "Calc")[0] + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + recordPath := filepath.Join(t.TempDir(), "compiler-arguments") + compilerPath, err := filepath.Abs(os.Args[0]) + require.NoError(t, err) + root.API.Meta = make(expr.MetaExpr) + root.API.GRPC.Services[0].ServiceExpr.Meta = make(expr.MetaExpr) + root.API.Meta["protoc:cmd"] = []string{ + compilerPath, + "-test.run=TestProtobufCompilerProcess", + "--", + recordPath, + } + root.API.Meta["protoc:include"] = []string{"api-before"} + root.API.GRPC.Services[0].ServiceExpr.Meta["protoc:include"] = []string{"service-before"} + + resolver := protobufToolResolver{ + resolve: func(name string) (string, error) { + switch name { + case compilerPath: + return compilerPath, nil + case protocGenGoName: + return "/planned/protoc-gen-go", nil + case protocGenGoGRPCName: + return "/planned/protoc-gen-go-grpc", nil + default: + t.Fatalf("unexpected executable lookup %q", name) + return "", nil + } + }, + version: func(path string) (string, error) { + switch path { + case "/planned/protoc-gen-go": + return protocGenGoVersion, nil + case "/planned/protoc-gen-go-grpc": + return protocGenGoGRPCVersion, nil + default: + t.Fatalf("unexpected version check %q", path) + return "", nil + } + }, + } + plans, err := newPlans(generation, resolver, PlanInput{Root: root, Service: services[0]}) + require.NoError(t, err) + + root.API.Meta["protoc:cmd"] = []string{"compiler-after"} + root.API.Meta["protoc:include"] = []string{"api-after"} + root.API.GRPC.Services[0].ServiceExpr.Meta["protoc:include"] = []string{"service-after"} + t.Setenv("PATH", t.TempDir()) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + + grpcService := root.API.GRPC.Services[0] + renderData := newServicesData(services[0].Services(), plans[0]) + renderData.GRPCServices[grpcService.Name()] = &ServiceData{ + Service: services[0].Services().Get(grpcService.Name()), + } + files := protoFiles(renderData) + require.Len(t, files, 1) + t.Setenv("GO_WANT_PROTOBUF_COMPILER_PROCESS", "1") + require.NoError(t, files[0].FinalizeFunc(filepath.Join(t.TempDir(), "service.proto"))) + encoded, err := os.ReadFile(recordPath) + require.NoError(t, err) + arguments := strings.Split(string(encoded), "\n") + require.Contains(t, arguments, "--plugin=protoc-gen-go=/planned/protoc-gen-go") + require.Contains(t, arguments, "--plugin=protoc-gen-go-grpc=/planned/protoc-gen-go-grpc") + require.Contains(t, arguments, "service-before") + require.Contains(t, arguments, "api-before") + require.NotContains(t, arguments, "service-after") + require.NotContains(t, arguments, "api-after") +} + +// TestNewPlansChecksProtobufPluginVersions checks both required plugin +// versions before planning succeeds. +func TestNewPlansChecksProtobufPluginVersions(t *testing.T) { + tests := []struct { + name string + plugin string + gotVersion string + wantVersion string + }{ + {"Go plugin", protocGenGoName, "protoc-gen-go v1.36.11", protocGenGoVersion}, + {"gRPC plugin", protocGenGoGRPCName, "protoc-gen-go-grpc 1.6.1", protocGenGoGRPCVersion}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := grpcPlanRoots(t, "Calc")[0] + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + resolver := fixedProtobufToolResolver() + resolver.version = func(path string) (string, error) { + if filepath.Base(path) == test.plugin { + return test.gotVersion, nil + } + if filepath.Base(path) == protocGenGoName { + return protocGenGoVersion, nil + } + return protocGenGoGRPCVersion, nil + } + _, err := newPlans(generation, resolver, PlanInput{Root: root, Service: services[0]}) + require.EqualError(t, err, "protobuf plugin "+test.plugin+" reports version "+ + test.gotVersion+", want "+test.wantVersion) + }) + } +} + +// TestResolveProtobufPluginAcceptsWindowsExecutableName checks the version text +// printed when Windows adds its executable suffix to the plugin name. +func TestResolveProtobufPluginAcceptsWindowsExecutableName(t *testing.T) { + resolver := fixedProtobufToolResolver() + resolver.version = func(string) (string, error) { + return "protoc-gen-go.exe v1.36.12", nil + } + + plugin, err := resolveProtobufPlugin(resolver, protocGenGoName, protocGenGoVersion) + + require.NoError(t, err) + require.Equal(t, "/tools/protoc-gen-go", plugin) +} + +// TestNewPlansRejectsGoPluginOverrides checks every protoc flag form that +// could replace either required Go plugin. +func TestNewPlansRejectsGoPluginOverrides(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {"Go plugin equals", []string{"--plugin=protoc-gen-go=/other/go"}}, + {"gRPC plugin equals", []string{"--plugin=protoc-gen-go-grpc=/other/grpc"}}, + {"Go plugin separate", []string{"--plugin", "protoc-gen-go=/other/go"}}, + {"gRPC plugin separate", []string{"--plugin", "protoc-gen-go-grpc=/other/grpc"}}, + {"Go plugin path", []string{"--plugin=/other/protoc-gen-go"}}, + {"gRPC plugin path", []string{"--plugin=/other/protoc-gen-go-grpc"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := grpcPlanRoots(t, "Calc")[0] + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + root.API.Meta = make(expr.MetaExpr) + root.API.Meta["protoc:cmd"] = append([]string{"protoc"}, test.args...) + _, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: root, Service: services[0]}, + ) + require.ErrorContains(t, err, `Meta("protoc:cmd") cannot replace`) + }) + } +} + +// TestProtobufCompilerProcess records the compiler arguments for its parent +// test and exits without compiling the schema. +func TestProtobufCompilerProcess(t *testing.T) { + if os.Getenv("GO_WANT_PROTOBUF_COMPILER_PROCESS") != "1" { + return + } + separator := -1 + for index, argument := range os.Args { + if argument == "--" { + separator = index + break + } + } + if separator < 0 || len(os.Args) <= separator+1 { + os.Exit(2) + } + recordPath := os.Args[separator+1] + arguments := strings.Join(os.Args[separator+2:], "\n") + if err := os.WriteFile(recordPath, []byte(arguments), 0o600); err != nil { + os.Exit(3) + } + os.Exit(0) +} + +// fixedProtobufToolResolver returns stable paths and required versions. +func fixedProtobufToolResolver() protobufToolResolver { + return protobufToolResolver{ + resolve: func(name string) (string, error) { + return "/tools/" + filepath.Base(name), nil + }, + version: func(path string) (string, error) { + if filepath.Base(path) == protocGenGoName { + return protocGenGoVersion, nil + } + return protocGenGoGRPCVersion, nil + }, + } +} + +// protoc compiles a schema directly for tests that inspect protobuf output. +func protoc(command []string, path string) error { + if len(command) == 0 { + return fmt.Errorf("protobuf compiler command is empty") + } + resolver := systemProtobufTools() + goPlugin, err := resolveProtobufPlugin(resolver, protocGenGoName, protocGenGoVersion) + if err != nil { + return err + } + goGRPCPlugin, err := resolveProtobufPlugin(resolver, protocGenGoGRPCName, protocGenGoGRPCVersion) + if err != nil { + return err + } + compiler, err := resolver.resolve(command[0]) + if err != nil { + return fmt.Errorf("resolve protobuf compiler %q: %w", command[0], err) + } + return runProtoc(&protobufToolPlan{ + command: append([]string{compiler}, command[1:]...), + goPlugin: goPlugin, + goGRPCPlugin: goGRPCPlugin, + }, path) +} diff --git a/grpc/codegen/protobuf_transform.go b/grpc/codegen/protobuf_transform.go index addd57b591..308f8cdd19 100644 --- a/grpc/codegen/protobuf_transform.go +++ b/grpc/codegen/protobuf_transform.go @@ -1,3 +1,4 @@ +// This file writes Go conversions between service values and protobuf values. package codegen import ( @@ -7,80 +8,66 @@ import ( "goa.design/goa/v3/expr" ) -// protoBufTransform produces Go code to initialize a data structure defined -// by target from an instance of data structure defined by source. The source -// or target is a protocol buffer type. The transformation is generated by the -// shared transform engine specialized via the protocol buffer transform hooks -// (see protoHooks). -// -// source, target are the source and target attributes used in transformation -// -// sourceVar, targetVar are the source and target variables -// -// sourceCtx, targetCtx are the source and target attribute contexts -// -// `proto` param if true indicates that the target is a protocol buffer type -// -// newVar if true initializes a target variable with the generated Go code -// using `:=` operator. If false, it assigns Go code to the target variable -// using `=`. +// protoBufTransform writes code that copies sourceVar into targetVar. One side +// is a service value and the other is a protobuf value. proto is true when the +// target is the protobuf value. newVar chooses between := and =. func protoBufTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext, proto, newVar bool) (string, []*codegen.TransformFunctionData, error) { prefix := "protobuf" if proto { + original := target target = expr.DupAtt(target) + targetCtx.Scope.(*protoBufScope).service.protobuf.plan.bindAttributeCopy(original, target) removeMeta(target) prefix = "svc" } else { + original := source source = expr.DupAtt(source) + sourceCtx.Scope.(*protoBufScope).service.protobuf.plan.bindAttributeCopy(original, source) removeMeta(source) } ta := &codegen.TransformAttrs{ SourceCtx: sourceCtx, TargetCtx: targetCtx, Prefix: prefix, - Hooks: protoHooks(proto, targetCtx), + Hooks: protoHooks(proto), } return codegen.GoTransformWithAttrs(source, target, sourceVar, targetVar, ta, newVar) } -// removeMeta removes meta attributes from the given attribute that cannot be -// honored. This is needed to make sure that any field name overridding is -// removed when generating protobuf types (as protogen itself won't honor these -// overrides). +// removeMeta removes service field and package settings from a protobuf copy. +// The protobuf compiler does not use these settings when it writes Go types. func removeMeta(att *expr.AttributeExpr) { - _ = codegen.Walk(att, func(a *expr.AttributeExpr) error { + err := codegen.Walk(att, func(a *expr.AttributeExpr) error { delete(a.Meta, "struct:field:name") delete(a.Meta, "struct:field:external") delete(a.Meta, "struct.field.external") // Deprecated syntax. Only present for backward compatibility. + delete(a.Meta, "struct:pkg:path") return nil }) + if err != nil { + panic(fmt.Sprintf("remove protobuf metadata: %s", err)) + } } -// convertType produces code to initialize a target type from a source type -// held by srcVar. proto is true when the transformation initializes a -// protocol buffer type. -// NOTE: For Int and UInt kinds, protocol buffer Go compiler generates -// int32 and uint32 respectively whereas Goa generates int and uint. +// convertType writes the expression that converts srcVar to the target type. +// proto is true when the target is a protobuf value. Protobuf uses int32 and +// uint32 where Goa uses int and uint. func convertType(src, tgt *expr.AttributeExpr, srcPtr, tgtPtr bool, srcVar string, proto bool, ta *codegen.TransformAttrs) string { + if protoUnionBranchUsesHelper(src, tgt) { + return fmt.Sprintf("%s(%s)", codegen.TransformHelperName(src, tgt, ta), srcVar) + } if expr.IsAlias(src.Type) || expr.IsAlias(tgt.Type) { srcp, tgtp := unAlias(src), unAlias(tgt) - if srcp.Type == tgtp.Type { - if proto { - return convertPrimitiveToProto(src, tgtp, srcPtr, tgtPtr, srcVar) - } - return convertPrimitiveFromProto(srcp, tgt, srcPtr, tgtPtr, srcVar, ta) + if proto { + return convertPrimitiveToProto(src, tgtp, srcPtr, tgtPtr, srcVar) } - return fmt.Sprintf("%s(%s)", codegen.TransformHelperName(src, tgt, ta), srcVar) - } - - if _, ok := src.Type.(expr.UserType); ok { - return fmt.Sprintf("%s(%s)", codegen.TransformHelperName(src, tgt, ta), srcVar) + return convertPrimitiveFromProto(srcp, tgt, srcPtr, tgtPtr, srcVar, ta) } srcType, _ := codegen.GetMetaType(src) tgtType, _ := codegen.GetMetaType(tgt) if srcType == "" && tgtType == "" && (src.Type != expr.Int) && (src.Type != expr.UInt) && (src.Type != expr.Any) { - // Nothing to do, except for Any type which needs special conversion + // Any values need a protobuf conversion. Other matching values do not. return srcVar } @@ -90,6 +77,17 @@ func convertType(src, tgt *expr.AttributeExpr, srcPtr, tgtPtr bool, srcVar strin return convertPrimitiveFromProto(src, tgt, srcPtr, tgtPtr, srcVar, ta) } +// protoUnionBranchUsesHelper reports whether protobuf union rendering emits a +// TransformHelperName call for the branch. Planning and rendering use this +// same rule so their helper order cannot differ. +func protoUnionBranchUsesHelper(source, target *expr.AttributeExpr) bool { + if expr.IsAlias(source.Type) || expr.IsAlias(target.Type) { + return unAlias(source).Type != unAlias(target).Type + } + _, named := source.Type.(expr.UserType) + return named +} + const convertGoAnyToProtobufValueFunc = `func() *structpb.Value { // Convert Go any to protobuf Value directly if %s == nil { @@ -113,7 +111,7 @@ const convertProtobufValueToGoAnyFunc = `func() any { // convertPrimitiveToProto returns the code to convert a primitive type to its // protocol buffer representation. func convertPrimitiveToProto(_, tgt *expr.AttributeExpr, srcPtr, _ bool, srcVar string) string { - // Special handling for Any type conversion to google.protobuf.Value + // Any values use google.protobuf.Value in protobuf messages. if tgt.Type.Kind() == expr.AnyKind { if srcPtr { srcVar = "*" + srcVar @@ -132,7 +130,7 @@ func convertPrimitiveToProto(_, tgt *expr.AttributeExpr, srcPtr, _ bool, srcVar // convertPrimitiveFromProto returns the code to convert the protocol buffer // representation of a primitive type back to the service type. func convertPrimitiveFromProto(_, tgt *expr.AttributeExpr, srcPtr, _ bool, srcVar string, ta *codegen.TransformAttrs) string { - // Special handling for Any type conversion from google.protobuf.Value + // Any values arrive from protobuf as google.protobuf.Value. if tgt.Type.Kind() == expr.AnyKind { if srcPtr { srcVar = "*" + srcVar @@ -150,11 +148,3 @@ func convertPrimitiveFromProto(_, tgt *expr.AttributeExpr, srcPtr, _ bool, srcVa } return fmt.Sprintf("%s(%s)", tgtType, srcVar) } - -// protocOneofWrapperRef returns the reference to the Go wrapper type that -// protoc generates for a oneof field: it mirrors protoc generated Go oneof -// wrapper type naming which joins the parent message type name and the oneof -// field name with an underscore (Message_Field). -func protocOneofWrapperRef(message, fieldName string) string { - return message + "_" + fieldName -} diff --git a/grpc/codegen/protobuf_transform_test.go b/grpc/codegen/protobuf_transform_test.go index 413aab48a4..e57b4d449f 100644 --- a/grpc/codegen/protobuf_transform_test.go +++ b/grpc/codegen/protobuf_transform_test.go @@ -1,3 +1,5 @@ +// This file verifies generated transformations between service values and +// protobuf messages. package codegen import ( @@ -52,9 +54,8 @@ func TestProtoBufTransform(t *testing.T) { pkgOverride = root.UserType("CompositePkgOverride") // attribute contexts used in test cases - svcCtx = serviceTypeContext("proto", sd.Scope) + svcCtx = codegen.NewAttributeContext(false, false, true, "proto", sd.Scope) ptrCtx = pointerContext("proto", sd.Scope) - pbCtx = protoBufTypeContext("proto", sd.Scope, true) ) // gRPC does not support any @@ -67,7 +68,6 @@ func TestProtoBufTransform(t *testing.T) { nat.Attribute.Type = expr.String } } - tc := map[string][]struct { Name string Source expr.DataType @@ -169,11 +169,21 @@ func TestProtoBufTransform(t *testing.T) { srcCtx := c.Ctx tgtCtx := c.Ctx if c.ToProto { - target = makeProtoBufMessage(expr.DupAtt(target), target.Type.Name(), sd) - tgtCtx = pbCtx + target = makeProtoBufMessage( + expr.DupAtt(target), + target.Type.Name(), + testGRPCMessageExampleIdentity(name+"/"+c.Name+"/target"), + ) + freezeProtoBufTransformMessages(t, sd, target) + tgtCtx = protoBufTypeContext("proto", sd, true) } else { - source = makeProtoBufMessage(expr.DupAtt(source), source.Type.Name(), sd) - srcCtx = pbCtx + source = makeProtoBufMessage( + expr.DupAtt(source), + source.Type.Name(), + testGRPCMessageExampleIdentity(name+"/"+c.Name+"/source"), + ) + freezeProtoBufTransformMessages(t, sd, source) + srcCtx = protoBufTypeContext("proto", sd, true) } code, _, err := protoBufTransform(source, target, "source", "target", srcCtx, tgtCtx, c.ToProto, true) require.NoError(t, err) @@ -189,8 +199,15 @@ func TestProtoBufTransformAnyType(t *testing.T) { var ( sd = &ServiceData{Name: "Service", Scope: codegen.NewNameScope()} svcCtx = codegen.NewAttributeContext(false, false, true, "", sd.Scope) - pbCtx = protoBufTypeContext("", sd.Scope, false) ) + sd.protobuf = newProtobufPackageCatalog("") + sd.protobuf.plan = &protobufServicePlan{ + catalog: sd.protobuf, + fields: make(map[*expr.AttributeExpr]protocNameKey), + wrappers: make(map[*expr.AttributeExpr]protocNameKey), + oneofs: make(map[*expr.AttributeExpr]protocNameKey), + } + pbCtx := protoBufTypeContext("", sd, false) cases := []struct { Name string @@ -237,6 +254,15 @@ func TestProtoBufTransformAnyType(t *testing.T) { } } +// freezeProtoBufTransformMessages prepares the message names consumed by one +// standalone transformation test outside full service analysis. +func freezeProtoBufTransformMessages(t *testing.T, sd *ServiceData, attribute *expr.AttributeExpr) { + sd.protobuf = newProtobufPackageCatalog("proto") + require.NoError(t, sd.protobuf.collectMessage(attribute, protobufMessageSource{})) + planTestProtobufCatalog(t, sd) + sd.protobuf.freezeMessageNames() +} + func pointerContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { return codegen.NewAttributeContext(true, false, true, pkg, scope) } diff --git a/grpc/codegen/protoc_names.go b/grpc/codegen/protoc_names.go new file mode 100644 index 0000000000..0ae9d64bce --- /dev/null +++ b/grpc/codegen/protoc_names.go @@ -0,0 +1,275 @@ +// This file reads the Go names that the supported protobuf tools assign to a +// compiled protobuf file. The gRPC planner uses these names when it records +// declarations that later files define or call. +package codegen + +import ( + "fmt" + "strings" + + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +type ( + // protocNameRules reads names using one supported pair of protobuf tools. + protocNameRules struct{} + + // protocNames stores each Go name under the protobuf item and the way that + // generated code uses it. + protocNames struct { + values map[protocNameKey]string + } + + // protocNameKey identifies one Go name produced for a protobuf item. + protocNameKey struct { + descriptor string + role protocNameRole + } + + // protocNameRole identifies one Go declaration or field produced by the + // supported protobuf tools. + protocNameRole uint8 +) + +const ( + protocMessageName protocNameRole = iota + 1 + protocFieldName + protocEnumName + protocEnumValueName + protocOneofFieldName + protocOneofInterfaceName + protocOneofWrapperName + protocServiceClientName + protocServiceClientStructName + protocServiceClientConstructorName + protocServiceServerName + protocServiceUnimplementedServerName + protocServiceUnsafeServerName + protocServiceRegisterName + protocServiceDescriptorName + protocMethodName + protocMethodFullName + protocMethodHandlerName + protocMethodClientStreamName + protocMethodServerStreamName +) + +const protocNameVersionGo1_36GRPC1_6 = "protoc-gen-go-v1.36.12/protoc-gen-go-grpc-v1.6.2" + +// newProtocNameRules returns the name reader for a supported protobuf tool +// pair. An unknown value is rejected because its Go names may differ. +func newProtocNameRules(version string) (*protocNameRules, error) { + if version != protocNameVersionGo1_36GRPC1_6 { + return nil, fmt.Errorf("unsupported protobuf Go naming version %q", version) + } + return &protocNameRules{}, nil +} + +// file returns every Go name used for messages, fields, enumerations, oneofs, +// services, and methods in descriptor. +func (r *protocNameRules) file(descriptor *descriptorpb.FileDescriptorProto) (*protocNames, error) { + request := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{descriptor.GetName()}, + ProtoFile: []*descriptorpb.FileDescriptorProto{descriptor}, + } + plugin, err := (protogen.Options{}).New(request) + if err != nil { + return nil, fmt.Errorf("read protobuf Go names: %w", err) + } + if len(plugin.Files) != 1 || plugin.Files[0].Desc.Path() != descriptor.GetName() { + return nil, fmt.Errorf("read protobuf Go names: file %q was not returned", descriptor.GetName()) + } + + names := &protocNames{values: make(map[protocNameKey]string)} + file := plugin.Files[0] + for _, enum := range file.Enums { + if err := names.addEnum(enum); err != nil { + return nil, err + } + } + for _, message := range file.Messages { + if err := names.addMessage(message); err != nil { + return nil, err + } + } + for _, service := range file.Services { + if err := names.addService(service); err != nil { + return nil, err + } + } + return names, nil +} + +// lookup returns the Go name stored for descriptor and role. +func (n *protocNames) lookup(descriptor string, role protocNameRole) (string, bool) { + name, ok := n.values[protocNameKey{descriptor: descriptor, role: role}] + return name, ok +} + +// String returns the short role name used in test and error labels. +func (r protocNameRole) String() string { + switch r { + case protocMessageName: + return "message" + case protocFieldName: + return "field" + case protocEnumName: + return "enum" + case protocEnumValueName: + return "enum value" + case protocOneofFieldName: + return "oneof field" + case protocOneofInterfaceName: + return "oneof interface" + case protocOneofWrapperName: + return "oneof wrapper" + case protocServiceClientName: + return "service client" + case protocServiceClientStructName: + return "service client struct" + case protocServiceClientConstructorName: + return "service client constructor" + case protocServiceServerName: + return "service server" + case protocServiceUnimplementedServerName: + return "unimplemented service server" + case protocServiceUnsafeServerName: + return "unsafe service server" + case protocServiceRegisterName: + return "service register function" + case protocServiceDescriptorName: + return "service description" + case protocMethodName: + return "method" + case protocMethodFullName: + return "full method name" + case protocMethodHandlerName: + return "method handler" + case protocMethodClientStreamName: + return "client stream" + case protocMethodServerStreamName: + return "server stream" + default: + panic(fmt.Sprintf("unknown protobuf Go name role %d", r)) + } +} + +// addMessage stores one message, its nested declarations, fields, and oneofs. +func (n *protocNames) addMessage(message *protogen.Message) error { + if err := n.add(string(message.Desc.FullName()), protocMessageName, message.GoIdent.GoName); err != nil { + return err + } + for _, enum := range message.Enums { + if err := n.addEnum(enum); err != nil { + return err + } + } + for _, nested := range message.Messages { + if err := n.addMessage(nested); err != nil { + return err + } + } + for _, field := range message.Fields { + descriptor := string(field.Desc.FullName()) + if err := n.add(descriptor, protocFieldName, field.GoName); err != nil { + return err + } + if field.Oneof != nil && !field.Oneof.Desc.IsSynthetic() { + if err := n.add(descriptor, protocOneofWrapperName, field.GoIdent.GoName); err != nil { + return err + } + } + } + for _, oneof := range message.Oneofs { + if oneof.Desc.IsSynthetic() { + continue + } + descriptor := string(oneof.Desc.FullName()) + if err := n.add(descriptor, protocOneofFieldName, oneof.GoName); err != nil { + return err + } + if err := n.add(descriptor, protocOneofInterfaceName, "is"+oneof.GoIdent.GoName); err != nil { + return err + } + } + return nil +} + +// addEnum stores one enumeration and all of its values. +func (n *protocNames) addEnum(enum *protogen.Enum) error { + if err := n.add(string(enum.Desc.FullName()), protocEnumName, enum.GoIdent.GoName); err != nil { + return err + } + for _, value := range enum.Values { + if err := n.add(string(value.Desc.FullName()), protocEnumValueName, value.GoIdent.GoName); err != nil { + return err + } + } + return nil +} + +// addService stores the declarations written by protoc-gen-go-grpc v1.6 for +// one service and all of its methods. +func (n *protocNames) addService(service *protogen.Service) error { + descriptor := string(service.Desc.FullName()) + serviceName := service.GoName + declarations := []struct { + role protocNameRole + name string + }{ + {protocServiceClientName, serviceName + "Client"}, + {protocServiceClientStructName, protocGRPCUnexport(serviceName) + "Client"}, + {protocServiceClientConstructorName, "New" + serviceName + "Client"}, + {protocServiceServerName, serviceName + "Server"}, + {protocServiceUnimplementedServerName, "Unimplemented" + serviceName + "Server"}, + {protocServiceUnsafeServerName, "Unsafe" + serviceName + "Server"}, + {protocServiceRegisterName, "Register" + serviceName + "Server"}, + {protocServiceDescriptorName, serviceName + "_ServiceDesc"}, + } + for _, declaration := range declarations { + if err := n.add(descriptor, declaration.role, declaration.name); err != nil { + return err + } + } + for _, method := range service.Methods { + methodDescriptor := string(method.Desc.FullName()) + methodName := method.GoName + if err := n.add(methodDescriptor, protocMethodName, methodName); err != nil { + return err + } + if err := n.add(methodDescriptor, protocMethodFullName, serviceName+"_"+methodName+"_FullMethodName"); err != nil { + return err + } + if err := n.add(methodDescriptor, protocMethodHandlerName, "_"+serviceName+"_"+methodName+"_Handler"); err != nil { + return err + } + if method.Desc.IsStreamingClient() || method.Desc.IsStreamingServer() { + if err := n.add(methodDescriptor, protocMethodClientStreamName, serviceName+"_"+methodName+"Client"); err != nil { + return err + } + if err := n.add(methodDescriptor, protocMethodServerStreamName, serviceName+"_"+methodName+"Server"); err != nil { + return err + } + } + } + return nil +} + +// add stores one name and rejects two values for the same protobuf item and +// role. +func (n *protocNames) add(descriptor string, role protocNameRole, name string) error { + key := protocNameKey{descriptor: descriptor, role: role} + if previous, ok := n.values[key]; ok { + return fmt.Errorf("protobuf item %q has two %s names, %q and %q", descriptor, role, previous, name) + } + n.values[key] = name + return nil +} + +// protocGRPCUnexport changes the first letter exactly as protoc-gen-go-grpc +// v1.6 does when it writes the private client type. +func protocGRPCUnexport(name string) string { + return strings.ToLower(name[:1]) + name[1:] +} diff --git a/grpc/codegen/protoc_names_test.go b/grpc/codegen/protoc_names_test.go new file mode 100644 index 0000000000..7f66c33f0d --- /dev/null +++ b/grpc/codegen/protoc_names_test.go @@ -0,0 +1,170 @@ +// This file compares Goa's protobuf Go names with code generated by the +// supported protobuf tools. +package codegen + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" +) + +// TestProtocNameVersion rejects a name version that Goa does not support. +func TestProtocNameVersion(t *testing.T) { + _, err := newProtocNameRules("unknown") + require.EqualError(t, err, `unsupported protobuf Go naming version "unknown"`) +} + +// TestProtocDeclarationFamilies checks every generated name family that Goa +// uses in its gRPC client and server code. +func TestProtocDeclarationFamilies(t *testing.T) { + descriptor, declarations := generateProtocNameFixture(t) + rules, err := newProtocNameRules(protocNameVersionGo1_36GRPC1_6) + require.NoError(t, err) + names, err := rules.file(descriptor) + require.NoError(t, err) + + tests := []struct { + descriptor string + role protocNameRole + want string + }{ + {"goa.names.v1.api2_http_request", protocMessageName, "Api2HttpRequest"}, + {"goa.names.v1.api2_http_request.nested_api2", protocMessageName, "Api2HttpRequestNestedApi2"}, + {"goa.names.v1.wrapper_conflict._BranchValue", protocMessageName, "WrapperConflict_XBranchValue"}, + {"goa.names.v1.Explicit_HTTP2_Name", protocMessageName, "Explicit_HTTP2_Name"}, + {"goa.names.v1.api2_http_request.api_url", protocFieldName, "ApiUrl"}, + {"goa.names.v1.api2_http_request.api2_url", protocFieldName, "Api2Url"}, + {"goa.names.v1.api2_http_request.dns_2_server", protocFieldName, "Dns_2Server"}, + {"goa.names.v1.api2_http_request.x509_cert", protocFieldName, "X509Cert"}, + {"goa.names.v1.api2_http_request.reset", protocFieldName, "Reset_"}, + {"goa.names.v1.api2_http_request.string", protocFieldName, "String_"}, + {"goa.names.v1.api2_http_request.proto_message", protocFieldName, "ProtoMessage_"}, + {"goa.names.v1.api2_http_request.descriptor", protocFieldName, "Descriptor_"}, + {"goa.names.v1.Explicit_HTTP2_Name.Explicit_Field2_Name", protocFieldName, "Explicit_Field2_Name"}, + {"goa.names.v1.http_2_status", protocEnumName, "Http_2Status"}, + {"goa.names.v1.HTTP_2_STATUS_UNSPECIFIED", protocEnumValueName, "Http_2Status_HTTP_2_STATUS_UNSPECIFIED"}, + {"goa.names.v1.HTTP2_OK", protocEnumValueName, "Http_2Status_HTTP2_OK"}, + {"goa.names.v1.api2_http_request.nested_api2.state_2", protocEnumName, "Api2HttpRequestNestedApi2State_2"}, + {"goa.names.v1.api2_http_request.nested_api2.DNS2_READY", protocEnumValueName, "Api2HttpRequestNestedApi2_DNS2_READY"}, + {"goa.names.v1.api2_http_request.result_2_kind", protocOneofFieldName, "Result_2Kind"}, + {"goa.names.v1.api2_http_request.result_2_kind", protocOneofInterfaceName, "isApi2HttpRequest_Result_2Kind"}, + {"goa.names.v1.api2_http_request.http_2xx", protocOneofWrapperName, "Api2HttpRequest_Http_2Xx"}, + {"goa.names.v1.api2_http_request.api_url_value", protocOneofWrapperName, "Api2HttpRequest_ApiUrlValue"}, + {"goa.names.v1.wrapper_conflict.choiceValue", protocOneofFieldName, "ChoiceValue_"}, + {"goa.names.v1.wrapper_conflict.choiceValue", protocOneofInterfaceName, "isWrapperConflict_ChoiceValue_"}, + {"goa.names.v1.wrapper_conflict.branchValue", protocOneofWrapperName, "WrapperConflict_BranchValue"}, + {"goa.names.v1.wrapper_conflict.reset", protocOneofWrapperName, "WrapperConflict_Reset_"}, + {"goa.names.v1.api2_http_service", protocServiceClientName, "Api2HttpServiceClient"}, + {"goa.names.v1.api2_http_service", protocServiceClientStructName, "api2HttpServiceClient"}, + {"goa.names.v1.api2_http_service", protocServiceClientConstructorName, "NewApi2HttpServiceClient"}, + {"goa.names.v1.api2_http_service", protocServiceServerName, "Api2HttpServiceServer"}, + {"goa.names.v1.api2_http_service", protocServiceUnimplementedServerName, "UnimplementedApi2HttpServiceServer"}, + {"goa.names.v1.api2_http_service", protocServiceUnsafeServerName, "UnsafeApi2HttpServiceServer"}, + {"goa.names.v1.api2_http_service", protocServiceRegisterName, "RegisterApi2HttpServiceServer"}, + {"goa.names.v1.api2_http_service", protocServiceDescriptorName, "Api2HttpService_ServiceDesc"}, + {"goa.names.v1.api2_http_service.get_url2", protocMethodName, "GetUrl2"}, + {"goa.names.v1.api2_http_service.get_url2", protocMethodFullName, "Api2HttpService_GetUrl2_FullMethodName"}, + {"goa.names.v1.api2_http_service.get_url2", protocMethodHandlerName, "_Api2HttpService_GetUrl2_Handler"}, + {"goa.names.v1.api2_http_service.watch_dns2", protocMethodClientStreamName, "Api2HttpService_WatchDns2Client"}, + {"goa.names.v1.api2_http_service.watch_dns2", protocMethodServerStreamName, "Api2HttpService_WatchDns2Server"}, + {"goa.names.v1.api2_http_service.upload_api2", protocMethodClientStreamName, "Api2HttpService_UploadApi2Client"}, + {"goa.names.v1.api2_http_service.upload_api2", protocMethodServerStreamName, "Api2HttpService_UploadApi2Server"}, + {"goa.names.v1.api2_http_service.sync_x509", protocMethodClientStreamName, "Api2HttpService_SyncX509Client"}, + {"goa.names.v1.api2_http_service.sync_x509", protocMethodServerStreamName, "Api2HttpService_SyncX509Server"}, + } + + for _, test := range tests { + t.Run(test.descriptor+"/"+test.role.String(), func(t *testing.T) { + got, ok := names.lookup(test.descriptor, test.role) + require.True(t, ok, "name was not recorded") + require.Equal(t, test.want, got) + require.Contains(t, declarations, got, "the supported tools did not declare the predicted Go name") + }) + } +} + +// generateProtocNameFixture runs the supported tools and returns their input +// description and every Go name declared in their output. +func generateProtocNameFixture(t *testing.T) (*descriptorpb.FileDescriptorProto, map[string]struct{}) { + t.Helper() + directory := t.TempDir() + source, err := os.ReadFile(filepath.Join("testdata", "protoc_names.proto")) + require.NoError(t, err) + protoPath := filepath.Join(directory, "protoc_names.proto") + require.NoError(t, os.WriteFile(protoPath, source, 0o600)) + require.NoError(t, protoc(defaultProtocCmd, protoPath)) + + descriptorPath := filepath.Join(directory, "descriptor.pb") + args := defaultProtocCmd[1:len(defaultProtocCmd):len(defaultProtocCmd)] + args = append(args, + "--proto_path", directory, + "--descriptor_set_out", descriptorPath, + protoPath, + ) + output, err := exec.Command(defaultProtocCmd[0], args...).CombinedOutput() + require.NoError(t, err, string(output)) + + encoded, err := os.ReadFile(descriptorPath) + require.NoError(t, err) + return readProtocDescriptor(t, encoded), declaredGoNames( + t, + filepath.Join(directory, "protoc_names.pb.go"), + filepath.Join(directory, "protoc_names_grpc.pb.go"), + ) +} + +// readProtocDescriptorRequest converts a descriptor set into the request read +// by protobuf's public Go name code. +func readProtocDescriptor(t *testing.T, encoded []byte) *descriptorpb.FileDescriptorProto { + t.Helper() + set := &descriptorpb.FileDescriptorSet{} + require.NoError(t, proto.Unmarshal(encoded, set)) + require.Len(t, set.File, 1) + return set.File[0] +} + +// declaredGoNames returns package names, fields, and receiver methods declared +// by the generated files. +func declaredGoNames(t *testing.T, paths ...string) map[string]struct{} { + t.Helper() + result := make(map[string]struct{}) + for _, path := range paths { + file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + require.NoError(t, err) + for _, declaration := range file.Decls { + switch declaration := declaration.(type) { + case *ast.GenDecl: + for _, specification := range declaration.Specs { + switch specification := specification.(type) { + case *ast.TypeSpec: + result[specification.Name.Name] = struct{}{} + ast.Inspect(specification.Type, func(node ast.Node) bool { + field, ok := node.(*ast.Field) + if ok { + for _, name := range field.Names { + result[name.Name] = struct{}{} + } + } + return true + }) + case *ast.ValueSpec: + for _, name := range specification.Names { + result[name.Name] = struct{}{} + } + } + } + case *ast.FuncDecl: + result[declaration.Name.Name] = struct{}{} + } + } + } + return result +} diff --git a/grpc/codegen/released_streaming_name_test.go b/grpc/codegen/released_streaming_name_test.go new file mode 100644 index 0000000000..04ea0c9f33 --- /dev/null +++ b/grpc/codegen/released_streaming_name_test.go @@ -0,0 +1,39 @@ +// This file checks that one gRPC response conversion keeps its released public +// name when both the response encoder and a stream send method use it. +package codegen + +import ( + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +// TestReleasedStreamingResponseConstructorNames catches replacing an honest +// method response name with an internal type-based name when conversions merge. +func TestReleasedStreamingResponseConstructorNames(t *testing.T) { + t.Run("caller selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.ServerStreamingResultWithViewsDSL) + services := CreateGRPCServices(root) + types := serverTypeFiles(services) + servers := serverFiles(services) + + sections := append(types[0].Section("server-type-init"), servers[1].Section("response-encoder")...) + sections = append(sections, servers[0].Section("server-stream-send")...) + code := codegen.SectionsCode(t, sections) + testutil.AssertGo(t, "testdata/golden/released_streaming_response_constructors.go.golden", code) + }) + + t.Run("fixed collection view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.ClientStreamingResultCollectionWithExplicitViewDSL) + services := CreateGRPCServices(root) + types := serverTypeFiles(services) + servers := serverFiles(services) + + sections := append(types[0].Section("server-type-init"), servers[1].Section("response-encoder")...) + sections = append(sections, servers[0].Section("server-stream-send")...) + code := codegen.SectionsCode(t, sections) + testutil.AssertGo(t, "testdata/golden/released_fixed_view_collection_constructor.go.golden", code) + }) +} diff --git a/grpc/codegen/required_union_validation_test.go b/grpc/codegen/required_union_validation_test.go new file mode 100644 index 0000000000..949f380597 --- /dev/null +++ b/grpc/codegen/required_union_validation_test.go @@ -0,0 +1,85 @@ +// This file checks the validation functions generated for gRPC server requests +// and client responses which contain a required OneOf. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + d "goa.design/goa/v3/dsl" +) + +func TestRequiredUnionValidationUsesCompleteProtobufBranches(t *testing.T) { + root := RunGRPCDSL(t, requiredUnionValidationDSL) + services := CreateGRPCServices(root) + + for _, test := range []struct { + name string + files []*codegen.File + sectionName string + golden string + }{ + { + name: "server", + files: serverTypeFiles(services), + sectionName: "server-validate", + golden: "testdata/golden/server_types_server-required-union-validation.go.golden", + }, + { + name: "client", + files: clientTypeFiles(services), + sectionName: "client-validate", + golden: "testdata/golden/client_types_client-required-union-validation.go.golden", + }, + } { + t.Run(test.name, func(t *testing.T) { + require.Len(t, test.files, 1) + sections := test.files[0].Section(test.sectionName) + require.Len(t, sections, 1) + testutil.AssertGo(t, test.golden, codegen.SectionCode(t, sections[0])) + }) + } +} + +// requiredUnionValidationDSL creates branches whose values include scalars, +// messages, an empty message, a byte slice, a named string, and Any. +func requiredUnionValidationDSL() { + token := d.Type("Token", d.String) + detail := d.Type("Detail", func() { + d.Field(1, "label", d.String) + d.Required("label") + }) + inactive := d.Type("Inactive", func() {}) + request := d.Type("RequestChoice", func() { + d.OneOf("choice", func() { + d.Field(1, "number", d.Int, func() { d.Minimum(1) }) + d.Field(2, "detail", detail) + d.Field(3, "inactive", inactive) + d.Field(4, "blob", d.Bytes) + d.Field(5, "token", token) + d.Field(6, "metadata", d.Any) + }) + d.Required("choice") + }) + response := d.Type("ResponseChoice", func() { + d.OneOf("choice", func() { + d.Field(1, "number", d.Int, func() { d.Minimum(1) }) + d.Field(2, "detail", detail) + d.Field(3, "inactive", inactive) + d.Field(4, "blob", d.Bytes) + d.Field(5, "token", token) + d.Field(6, "metadata", d.Any) + }) + d.Required("choice") + }) + d.Service("UnionValidation", func() { + d.Method("Exchange", func() { + d.Payload(request) + d.Result(response) + d.GRPC(func() {}) + }) + }) +} diff --git a/grpc/codegen/server.go b/grpc/codegen/server.go index de2f957e6f..77e26fbfa2 100644 --- a/grpc/codegen/server.go +++ b/grpc/codegen/server.go @@ -1,3 +1,5 @@ +// This file renders gRPC servers and codecs per service; each returned file +// receives imports from the complete endpoint set it renders. package codegen import ( @@ -9,24 +11,21 @@ import ( "goa.design/goa/v3/expr" ) -// ServerFiles returns all the server files for every gRPC service. The files -// contain the server which implements the generated gRPC server interface and -// encoders and decoders to transform protocol buffer types and gRPC metadata -// into goa types and vice versa. -func ServerFiles(genpkg string, services *ServicesData) []*codegen.File { - svcLen := len(services.Root.API.GRPC.Services) +// serverFiles returns the planned server interfaces, encoders, and decoders. +func serverFiles(services *ServicesData) []*codegen.File { + svcLen := len(services.servicePlans) fw := make([]*codegen.File, 2*svcLen) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = serverFile(genpkg, svc, services) + for i, servicePlan := range services.servicePlans { + fw[i] = addEndpointImports(serverFile(servicePlan.expression, services), services, servicePlan) } - for i, svc := range services.Root.API.GRPC.Services { - fw[i+svcLen] = serverEncodeDecode(genpkg, svc, services) + for i, servicePlan := range services.servicePlans { + fw[i+svcLen] = addEndpointImports(serverEncodeDecode(servicePlan.expression, services), services, servicePlan) } return fw } // serverFile returns the files defining the gRPC server. -func serverFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func serverFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { var ( fpath string sections []*codegen.SectionTemplate @@ -35,6 +34,7 @@ func serverFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData ) { svcName := data.Service.PathName + outputPackage := path.Join(services.GenPkg(), "grpc", svcName, "server") fpath = filepath.Join(codegen.Gendir, "grpc", svcName, "server", "server.go") imports := []*codegen.ImportSpec{ {Path: "context"}, @@ -42,9 +42,8 @@ func serverFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), {Path: "google.golang.org/grpc/codes"}, - {Path: path.Join(genpkg, svcName), Name: data.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: data.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: data.PkgName}, + services.ServiceImport(outputPackage, svc.Name()), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), } for _, e := range data.Endpoints { if e.Request.StreamEnvelope != nil { @@ -52,6 +51,9 @@ func serverFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData break } } + if serviceHasCallerSelectedViewedServerStream(data) { + imports = append(imports, &codegen.ImportSpec{Path: "google.golang.org/grpc/metadata"}) + } sections = []*codegen.SectionTemplate{ codegen.Header(svc.Name()+" gRPC server", "server", imports), { @@ -123,7 +125,7 @@ func serverFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData // serverEncodeDecode returns the file defining the gRPC server encoding and // decoding logic. -func serverEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func serverEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { var ( fpath string sections []*codegen.SectionTemplate @@ -132,6 +134,7 @@ func serverEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv ) { svcName := data.Service.PathName + outputPackage := path.Join(services.GenPkg(), "grpc", svcName, "server") fpath = filepath.Join(codegen.Gendir, "grpc", svcName, "server", "encode_decode.go") title := fmt.Sprintf("%s gRPC server encoders and decoders", svc.Name()) imports := []*codegen.ImportSpec{ @@ -143,9 +146,14 @@ func serverEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv {Path: "google.golang.org/grpc/metadata"}, codegen.GoaImport(""), codegen.GoaNamedImport("grpc", "goagrpc"), - {Path: path.Join(genpkg, svcName), Name: data.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: data.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: data.PkgName}, + services.ServiceImport(outputPackage, svc.Name()), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + } + if serviceHasViewedResult(data) { + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) + } + if responseMetadataNeedsFormat(data) { + imports = append(imports, &codegen.ImportSpec{Path: "fmt"}) } sections = []*codegen.SectionTemplate{codegen.Header(title, "server", imports)} @@ -153,16 +161,16 @@ func serverEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv if e.Response.ServerConvert != nil { sections = append(sections, &codegen.SectionTemplate{ Name: "response-encoder", - Source: grpcTemplates.Read(grpcResponseEncoderT, grpcConvertTypeToStringP, "string_conversion"), + Source: grpcTemplates.Read(grpcResponseEncoderT, grpcTypeToStringExpressionP), Data: e, FuncMap: map[string]any{ - "typeConversionData": typeConversionData, + "typeStringExpressionData": typeStringExpressionData, "metadataEncodeDecodeData": metadataEncodeDecodeData, }, }) } if e.PayloadRef != "" { - fm := transTmplFuncs(svc, services) + fm := transTmplFuncs(data) fm["isEmpty"] = isEmpty sections = append(sections, &codegen.SectionTemplate{ Name: "request-decoder", @@ -176,21 +184,61 @@ func serverEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv return &codegen.File{Path: fpath, SectionTemplates: sections} } -func transTmplFuncs(s *expr.GRPCServiceExpr, services *ServicesData) map[string]any { +// requestMetadataNeedsFormat reports whether request metadata can contain a Go +// value whose concrete type is unknown until the client runs. +func requestMetadataNeedsFormat(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + if metadataNeedsFormat(endpoint.Request.Metadata) { + return true + } + } + return false +} + +// responseMetadataNeedsFormat reports whether response metadata can contain a +// Go value whose concrete type is unknown until the server runs. +func responseMetadataNeedsFormat(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + for _, group := range [][]*MetadataData{endpoint.Response.Headers, endpoint.Response.Trailers} { + if metadataNeedsFormat(group) { + return true + } + } + } + return false +} + +// metadataNeedsFormat reports whether one metadata field uses Goa's Any type. +// All other supported metadata types have an exact string conversion. +func metadataNeedsFormat(fields []*MetadataData) bool { + for _, field := range fields { + typeKind := field.Type.Kind() + if array := expr.AsArray(field.Type); array != nil { + typeKind = array.ElemType.Type.Kind() + } + if typeKind == expr.AnyKind { + return true + } + } + return false +} + +// transTmplFuncs returns the type formatter used by metadata templates for one +// saved service. +func transTmplFuncs(service *ServiceData) map[string]any { return map[string]any{ "goTypeRef": func(dt expr.DataType) string { - return services.ServicesData.Get(s.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return service.Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, } } -// typeConversionData produces the template data suitable for executing the -// "type_conversion" template. -func typeConversionData(dt expr.DataType, varName, target string) map[string]any { +// typeStringExpressionData describes one primitive value that generated code +// converts to a metadata string. +func typeStringExpressionData(dt expr.DataType, target string) map[string]any { return map[string]any{ - "Type": dt, - "VarName": varName, - "Target": target, + "Type": dt, + "Target": target, } } diff --git a/grpc/codegen/server_protobuf_method_name_test.go b/grpc/codegen/server_protobuf_method_name_test.go new file mode 100644 index 0000000000..09d0587b65 --- /dev/null +++ b/grpc/codegen/server_protobuf_method_name_test.go @@ -0,0 +1,97 @@ +// This file checks that Goa's gRPC server methods use the names written by +// protoc when two design method names produce the same Go spelling. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestServerUsesProtobufMethodNames checks both source orders. Each generated +// server method must match the protobuf method for the same endpoint. +func TestServerUsesProtobufMethodNames(t *testing.T) { + for _, reverse := range []bool{false, true} { + name := "underscored first" + if reverse { + name = "camel case first" + } + t.Run(name, func(t *testing.T) { + root := RunGRPCDSL(t, collidingProtobufMethodDSL(reverse)) + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlans[0].Link()) + require.NoError(t, plans[0].Link()) + services := plans[0].services + service := services.Get("Values") + sections := serverFiles(services)[0].Section("server-grpc-interface") + require.Len(t, sections, len(service.Endpoints)) + + foundDifferentName := false + for index, endpoint := range service.Endpoints { + foundDifferentName = foundDifferentName || endpoint.GRPCMethodName != endpoint.Method.VarName + code := codegen.SectionCode(t, sections[index]) + require.Contains(t, code, "func (s *"+endpoint.ServerStruct+") "+endpoint.GRPCMethodName+"(") + } + require.True(t, foundDifferentName, "test methods did not exercise different service and protobuf names") + compileProtobufMethodServer(t, plans[0], servicePlans) + }) + } +} + +// collidingProtobufMethodDSL defines two methods that produce the same initial +// Go name but use different request and response messages. +func collidingProtobufMethodDSL(reverse bool) func() { + return func() { + underscored := func() { + dsl.Method("read_value", func() { + dsl.Payload(func() { dsl.Field(1, "text", dsl.String) }) + dsl.Result(func() { dsl.Field(1, "text", dsl.String) }) + dsl.GRPC(func() {}) + }) + } + camelCase := func() { + dsl.Method("readValue", func() { + dsl.Payload(func() { dsl.Field(1, "number", dsl.Int) }) + dsl.Result(func() { dsl.Field(1, "number", dsl.Int) }) + dsl.GRPC(func() {}) + }) + } + dsl.Service("Values", func() { + if reverse { + camelCase() + underscored() + return + } + underscored() + camelCase() + }) + } +} + +// compileProtobufMethodServer writes the service and transport files and asks +// Go to check that the server implements the generated protobuf interface. +func compileProtobufMethodServer(t *testing.T, plan *Plan, servicePlans []*service.Plan) { + t.Helper() + files, err := service.Files(servicePlans...) + require.NoError(t, err) + files = append(files, plan.ServerFiles()...) + files = append(files, plan.ClientFiles()...) + files = append(files, plan.ServerTypeFiles()...) + files = append(files, plan.ClientTypeFiles()...) + files = append(files, plan.ProtoFiles()...) + moduleDir := t.TempDir() + writeProtobufDescriptorModule(t, moduleDir) + for _, file := range files { + _, err := file.Render(moduleDir) + require.NoError(t, err) + } + compileProtobufDescriptorModule(t, moduleDir) +} diff --git a/grpc/codegen/server_test.go b/grpc/codegen/server_test.go index bd8f52c834..56f6283f91 100644 --- a/grpc/codegen/server_test.go +++ b/grpc/codegen/server_test.go @@ -33,7 +33,7 @@ func TestServerGRPCInterface(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles("", services) + fs := serverFiles(services) require.Len(t, fs, 2) sections := fs[0].Section("server-grpc-interface") require.NotEmpty(t, sections) @@ -61,7 +61,7 @@ func TestServerHandlerInit(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles("", services) + fs := serverFiles(services) require.Len(t, fs, 2) sections := fs[0].Section("grpc-handler-init") require.NotEmpty(t, sections) @@ -82,6 +82,7 @@ func TestRequestDecoder(t *testing.T) { {"request-decoder-payload-primitive", testdata.ServerStreamingRPCDSL}, {"request-decoder-payload-primitive-with-streaming-payload", testdata.ClientStreamingRPCWithPayloadDSL}, {"request-decoder-payload-user-type-with-streaming-payload", testdata.BidirectionalStreamingRPCWithPayloadDSL}, + {"request-decoder-metadata-only-payload-with-streaming-payload", testdata.ClientStreamingRPCWithMetadataOnlyPayloadDSL}, {"request-decoder-payload-primitive-with-streaming-payload-legacy-compat", testdata.ClientStreamingRPCWithPayloadLegacyCompatDSL}, {"request-decoder-payload-user-type-with-streaming-payload-legacy-compat", testdata.BidirectionalStreamingRPCWithPayloadLegacyCompatDSL}, {"request-decoder-payload-with-metadata-with-streaming-payload-legacy-compat", testdata.BidirectionalStreamingRPCWithMetadataLegacyCompatDSL}, @@ -93,7 +94,7 @@ func TestRequestDecoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles("", services) + fs := serverFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("request-decoder") require.NotEmpty(t, sections) @@ -121,7 +122,7 @@ func TestResponseEncoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles("", services) + fs := serverFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("response-encoder") require.NotEmpty(t, sections) diff --git a/grpc/codegen/server_types_test.go b/grpc/codegen/server_types_test.go index 5aea024276..e11fa0487a 100644 --- a/grpc/codegen/server_types_test.go +++ b/grpc/codegen/server_types_test.go @@ -28,12 +28,15 @@ func TestServerTypeFiles(t *testing.T) { {"server-struct-meta-type", testdata.StructMetaTypeDSL}, {"server-struct-field-name-meta-type", testdata.StructFieldNameMetaTypeDSL}, {"server-default-fields", testdata.DefaultFieldsDSL}, + {"server-result-with-views", testdata.MessageResultTypeWithViewsDSL}, + {"server-result-with-explicit-view", testdata.MessageResultTypeWithExplicitViewDSL}, + {"server-streaming-result-with-views", testdata.ServerStreamingResultWithViewsDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerTypeFiles("", services) + fs := serverTypeFiles(services) require.Len(t, fs, 1) var buf bytes.Buffer for _, s := range fs[0].SectionTemplates[1:] { diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index fb3f0fd690..90d6033bd1 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -1,10 +1,15 @@ +// This file analyzes gRPC endpoint designs into the immutable data consumed by +// protobuf message, client, server, conversion, and validation templates. package codegen import ( "fmt" + "path" + "reflect" "strings" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) @@ -14,7 +19,17 @@ type ( // indexed by service name. ServicesData struct { *service.ServicesData - GRPCServices map[string]*ServiceData + GRPCServices map[string]*ServiceData + cliPlan *grpcCLIPlan + protobuf map[*expr.GRPCServiceExpr]*protobufServicePlan + tools map[*expr.GRPCServiceExpr]*protobufToolPlan + symbols map[*expr.GRPCServiceExpr]*grpcSymbols + expressions []*expr.GRPCServiceExpr + servicePlans []*grpcServicePlan + serviceByExpr map[*expr.GRPCServiceExpr]*ServiceData + endpointPlans map[*expr.GRPCEndpointExpr]*grpcEndpointPlan + metadataPlans map[*expr.MappedAttributeExpr][]*grpcMetadataPlan + generation *codegen.Generation } // ServiceData contains the data used to render the code related to a @@ -22,8 +37,20 @@ type ( ServiceData struct { // Service contains the related service data. Service *service.Data + // ClientPkgName is the final alias for the generated gRPC client package. + ClientPkgName string + // ServerPkgName is the final alias for the generated gRPC server package. + ServerPkgName string // PkgName is the name of the generated package in *.pb.go. PkgName string + // ClientProtobufPkgName is the protobuf import name in the generated client package. + ClientProtobufPkgName string + // ServerProtobufPkgName is the protobuf import name in the generated server package. + ServerProtobufPkgName string + // ClientServicePkgName is the service import name in the generated client package. + ClientServicePkgName string + // ServerServicePkgName is the service import name in the generated server package. + ServerServicePkgName string // ProtoImports is the list of proto package imports. ProtoImports []string // Name is the service name. @@ -34,14 +61,34 @@ type ( Endpoints []*EndpointData // Messages describes the message data for this service. Messages []*service.UserTypeData - // ServerStruct is the name of the gRPC server struct. + // ServerStruct is the server type name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerStructDeclaration.Name() after planning. ServerStruct string - // ClientStruct is the name of the gRPC client struct, + // ServerStructDeclaration supplies the generated server type name. + ServerStructDeclaration *codegen.NameDeclaration + // ClientStruct is the client type name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning. ClientStruct string - // ServerInit is the name of the constructor of the server struct. + // ClientStructDeclaration supplies the generated client type name. + ClientStructDeclaration *codegen.NameDeclaration + // ServerInit is the server constructor name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerInitDeclaration.Name() after planning. ServerInit string - // ClientInit is the name of the constructor of the client struct. + // ServerInitDeclaration supplies the generated server constructor name. + ServerInitDeclaration *codegen.NameDeclaration + // ClientInit is the client constructor name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientInitDeclaration.Name() after planning. ClientInit string + // ClientInitDeclaration supplies the generated client constructor name. + ClientInitDeclaration *codegen.NameDeclaration // ServerInterface is the name of the gRPC server interface implemented // by the service. ServerInterface string @@ -51,12 +98,25 @@ type ( // ClientInterfaceInit is the name of the client constructor function in // the generated pb.go package. ClientInterfaceInit string - // Scope is the name scope for protocol buffers + // UnimplementedServer is the generated server type embedded by Goa's + // server implementation. + UnimplementedServer string + // RegisterFunction is the generated function that registers the server. + RegisterFunction string + // Scope records and returns unique Go names for protobuf fields and types in + // this service package. Scope *codegen.NameScope - // transformHelpers is the list of transform functions required by the - // constructors. - transformHelpers []*codegen.TransformFunctionData + // protobuf contains the messages and validation functions written for this + // service. + protobuf *protobufPackageCatalog + + // clientTransformHelpers contains recursive conversion functions written + // in the generated client package. + clientTransformHelpers []*codegen.TransformFunctionData + // serverTransformHelpers contains recursive conversion functions written + // in the generated server package. + serverTransformHelpers []*codegen.TransformFunctionData // validations contain the data to generate the validation functions to // validate the initialized type. validations []*ValidationData @@ -71,14 +131,40 @@ type ( PkgName string // ServicePkgName is the name of the service package name. ServicePkgName string + // ClientProtobufPkgName is the protobuf import name in the generated client package. + ClientProtobufPkgName string + // ServerProtobufPkgName is the protobuf import name in the generated server package. + ServerProtobufPkgName string + // ClientServicePkgName is the service import name in the generated client package. + ClientServicePkgName string + // ServerServicePkgName is the service import name in the generated server package. + ServerServicePkgName string // Method is the data for the underlying method expression. Method *service.MethodData + // ProtoMethodName is the method name written to the protobuf service. + ProtoMethodName string + // ClientMethodName is the final protobuf client method name kept for + // existing plugins. + // + // Deprecated: Use ProtoMethodName. + ClientMethodName string + // FullMethodName is the protobuf service and method name logged when the + // generated server starts. + FullMethodName string // PayloadType is the type of the payload. PayloadType expr.DataType // PayloadRef is the fully qualified reference to the method payload. PayloadRef string + // ClientPayloadRef is the payload reference in the generated client package. + ClientPayloadRef string + // ServerPayloadRef is the payload reference in the generated server package. + ServerPayloadRef string // ResultRef is the fully qualified reference to the method result. ResultRef string + // ClientResultRef is the result reference in the generated client package. + ClientResultRef string + // ServerResultRef is the result reference in the generated server package. + ServerResultRef string // ViewedResultRef is the fully qualified reference to the viewed result. ViewedResultRef string // Request is the gRPC request data. @@ -96,8 +182,13 @@ type ( // server side - // ServerStruct is the name of the gRPC server struct. + // ServerStruct is the server type name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerStructDeclaration.Name() after planning. ServerStruct string + // ServerStructDeclaration supplies the generated server type name. + ServerStructDeclaration *codegen.NameDeclaration // ServerInterface is the name of the gRPC server interface implemented // by the service. ServerInterface string @@ -106,15 +197,63 @@ type ( // client side - // ClientMethodName is the name of the gRPC method generated by protoc-gen-go. - ClientMethodName string - // ClientStruct is the name of the gRPC client struct, + // GRPCMethodName is the Go method name written by protoc-gen-go-grpc for + // both its client and server interfaces. + GRPCMethodName string + // ClientBuild is the remote call builder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientBuildDeclaration.Name() after planning. + ClientBuild string + // ClientBuildDeclaration supplies the generated remote call builder name. + ClientBuildDeclaration *codegen.NameDeclaration + // ClientEncode is the request encoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientEncodeDeclaration.Name() after planning. + ClientEncode string + // ClientEncodeDeclaration supplies the generated request encoder name. + ClientEncodeDeclaration *codegen.NameDeclaration + // ClientDecode is the response decoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientDecodeDeclaration.Name() after planning. + ClientDecode string + // ClientDecodeDeclaration supplies the generated response decoder name. + ClientDecodeDeclaration *codegen.NameDeclaration + // ClientStruct is the client type name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning. ClientStruct string + // ClientStructDeclaration supplies the generated client type name. + ClientStructDeclaration *codegen.NameDeclaration // ClientInterface is the name of the gRPC client interface implemented // by the service. ClientInterface string // ClientStream is the client stream data. ClientStream *StreamData + // ServerHandler is the handler constructor name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerHandlerDeclaration.Name() after planning. + ServerHandler string + // ServerHandlerDeclaration supplies the generated handler constructor name. + ServerHandlerDeclaration *codegen.NameDeclaration + // ServerDecode is the request decoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerDecodeDeclaration.Name() after planning. + ServerDecode string + // ServerDecodeDeclaration supplies the generated request decoder name. + ServerDecodeDeclaration *codegen.NameDeclaration + // ServerEncode is the response encoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerEncodeDeclaration.Name() after planning. + ServerEncode string + // ServerEncodeDeclaration supplies the generated response encoder name. + ServerEncodeDeclaration *codegen.NameDeclaration } // MetadataData describes a gRPC metadata field. @@ -130,9 +269,19 @@ type ( FieldName string // FieldType is the type of the struct field. FieldType expr.DataType + // ServiceAttribute is the service field populated from this metadata. + ServiceAttribute *expr.AttributeExpr + // WireAttribute is an independent copy of the native gRPC metadata value. + WireAttribute *expr.AttributeExpr // VarName is the name of the Go variable used to read or // convert the metadata value. VarName string + // WireVarName is the local variable produced before metadata encoding. + WireVarName string + // EncodeCode converts the service field to WireVarName. + EncodeCode string + // DecodeCode converts VarName to the exact service constructor field. + DecodeCode string // TypeName is the name of the type. TypeName string // TypeRef is the reference to the type. @@ -145,16 +294,24 @@ type ( StringSlice bool // Slice is true if the metadata value type is an array. Slice bool - // MapStringSlice is true if the metadata value type is a map of string - // slice. + // MapStringSlice reports whether the metadata value is a map from strings + // to string arrays. Valid current designs always set it to false. + // + // Deprecated: gRPC metadata accepts only primitive values and arrays. MapStringSlice bool - // Map is true if the metadata value type is a map. + // Map reports whether the metadata value is a map. Valid current designs + // always set it to false. + // + // Deprecated: gRPC metadata accepts only primitive values and arrays. Map bool // Type describes the datatype of the variable value. Mainly // used for conversion. Type expr.DataType // Validate contains the validation code if any. Validate string + // CLIPlan describes how command-line text becomes this metadata value and + // how the generated payload builder validates it. + CLIPlan *cli.FlagPlan // DefaultValue contains the default value if any. DefaultValue any // Example is an example value. @@ -176,15 +333,23 @@ type ( // RequestData describes a gRPC request. RequestData struct { + // ProtoMessageName is the message name written in the .proto method. + ProtoMessageName string // Description is the request description. Description string // Message is the gRPC request message used by the transport. For // streaming payload methods with an initial payload frame, this is the // synthesized stream envelope. Message *service.UserTypeData + // ClientMessageRef is the request message reference in the generated client package. + ClientMessageRef string + // ServerMessageRef is the request message reference in the generated server package. + ServerMessageRef string // PayloadMessage is the gRPC message that carries the one-shot method // payload fields before any stream envelope wrapping. PayloadMessage *service.UserTypeData + // ServerPayloadMessageRef is the one-shot payload message reference in the server package. + ServerPayloadMessageRef string // StreamEnvelope describes the synthesized stream envelope when the // transport must carry both the one-shot payload and streaming payload // items through the same streamed protobuf message. @@ -208,6 +373,9 @@ type ( // CLIArgs is the list of arguments for the command-line client. // This is set only for the client side. CLIArgs []*InitArgData + // CLIInitCode builds the service payload from command-line values in the + // generated client package. + CLIInitCode string } // StreamEnvelopeData describes a synthesized streamed protobuf envelope. @@ -219,20 +387,33 @@ type ( // InitialWrapperRef is the fully qualified protobuf wrapper type for the // initial payload branch. InitialWrapperRef string + // ClientInitialWrapperRef is the initial payload wrapper in the client package. + ClientInitialWrapperRef string + // ServerInitialWrapperRef is the initial payload wrapper in the server package. + ServerInitialWrapperRef string // StreamItemFieldName is the name of the streaming payload item branch // field. StreamItemFieldName string // StreamItemWrapperRef is the fully qualified protobuf wrapper type for // the streaming payload item branch. StreamItemWrapperRef string + // ClientStreamItemWrapperRef is the stream item wrapper in the client package. + ClientStreamItemWrapperRef string + // ServerStreamItemWrapperRef is the stream item wrapper in the server package. + ServerStreamItemWrapperRef string } // LegacyDecodeData describes how generated servers decode the one-shot // method payload that legacy stream protocol clients send in gRPC // request metadata. LegacyDecodeData struct { - // FuncName is the name of the generated legacy request decoder. + // FuncName is the legacy decoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use FuncDeclaration.Name() after planning. FuncName string + // FuncDeclaration supplies the generated legacy decoder name. + FuncDeclaration *codegen.NameDeclaration // Metadata lists the request metadata carrying the method payload // along with any explicitly mapped and security metadata. Metadata []*MetadataData @@ -245,12 +426,18 @@ type ( // ResponseData describes a gRPC success or error response. ResponseData struct { + // ProtoMessageName is the message name written in the .proto method. + ProtoMessageName string // StatusCode is the return code of the response. StatusCode string // Description is the response description. Description string // Message is the gRPC response message. Message *service.UserTypeData + // ClientMessageRef is the response message reference in the generated client package. + ClientMessageRef string + // ServerMessageRef is the response message reference in the generated server package. + ServerMessageRef string // Headers is the response header metadata. Headers []*MetadataData // Trailers is the response trailer metadata. @@ -259,10 +446,16 @@ type ( // initialize the generated response type in *.pb.go from the // method result type or the projected result type. ServerConvert *ConvertData + // ServerConverts lists the server conversion for each result view. It + // is empty for results without views. + ServerConverts []*ViewConvertData // ClientConvert is the type data with constructor function to // initialize the method result type or the projected result type // from the generated response type in *.pb.go. ClientConvert *ConvertData + // ClientConverts lists the client conversion for each result view. It + // is empty for results without views. + ClientConverts []*ViewConvertData } // ConvertData contains the data to convert source type to a target type. @@ -271,9 +464,11 @@ type ( // For response type, it contains data to transform gRPC response type to the // corresponding result type (client) and vice versa (server). ConvertData struct { - // SrcName is the fully qualified name of the source type. + // SrcName is the fully qualified name of the source type. It is empty + // when a streaming method builds its payload entirely from metadata. SrcName string - // SrcRef is the fully qualified reference to the source type. + // SrcRef is the fully qualified reference to the source type. It is empty + // when a streaming method builds its payload entirely from metadata. SrcRef string // TgtName is the fully qualified name of the target type. TgtName string @@ -289,10 +484,21 @@ type ( Validation *ValidationData } - // ValidationData contains the data necessary to render the validation - // function. + // ViewConvertData identifies the conversion generated for one result view. + ViewConvertData struct { + // View is the result view handled by Convert. + View string + // Convert builds the protobuf value using only fields in View. + Convert *ConvertData + } + + // ValidationData contains one generated validation function. ValidationData struct { - // Name is the validation function name. + // Declaration is the function name used by its definition and callers. + Declaration *codegen.NameDeclaration + // Name is the final validation function name kept for existing plugins. + // + // Deprecated: Use Declaration.Name() after planning. Name string // Def is the validation function definition. Def string @@ -310,6 +516,9 @@ type ( // InitData contains the data required to render a constructor. InitData struct { + // Declaration is the constructor declaration stored in the generated + // package and used by every call. + Declaration *codegen.NameDeclaration // Name is the constructor function name. Name string // Description is the function description. @@ -343,6 +552,8 @@ type ( // FieldType is the type of the data structure field that should be // initialized with the argument if any. FieldType expr.DataType + // InitCode converts and assigns this argument to the constructor result. + InitCode string // TypeName is the argument type name. TypeName string // TypeRef is the argument type reference. @@ -358,6 +569,9 @@ type ( // Validate contains the validation code for the argument // value if any. Validate string + // CLIPlan describes how command-line text becomes this argument value and + // how the generated payload builder validates it. + CLIPlan *cli.FlagPlan // Example is a example value Example any } @@ -365,8 +579,13 @@ type ( // StreamData contains data to render the stream struct type that implements // the service stream interface. StreamData struct { - // VarName is the name of the struct type. + // VarName is the stream type name kept for existing plugins. Changing it + // does not rename generated code. + // + // Deprecated: Use Declaration.Name() after planning. VarName string + // Declaration supplies the generated stream type name. + Declaration *codegen.NameDeclaration // Type is the stream type (client or server). Type string // ServiceInterface is the service interface that the struct implements. @@ -390,10 +609,16 @@ type ( // constructor to convert the service send type to the type expected by // the gRPC send type (in *.pb.go) SendConvert *ConvertData + // SendConverts lists the server send conversion for each result view. + // It is empty for client streams and results without views. + SendConverts []*ViewConvertData // RecvConvert is the type received through the stream. It contains the // constructor to convert the gRPC type (in *.pb.go) to the service receive // type. RecvConvert *ConvertData + // RecvConverts lists the client receive conversion for each result view. + // It is empty for server streams and results without views. + RecvConverts []*ViewConvertData // RecvName is the name of the receive function. RecvName string // RecvDesc is the description for the recv function. @@ -415,45 +640,100 @@ type ( validateKind int ) -// NewServicesData creates a new ServicesData instance for the given service data. -func NewServicesData(services *service.ServicesData) *ServicesData { - return &ServicesData{ - ServicesData: services, - GRPCServices: make(map[string]*ServiceData), - } -} - const ( // pbPkgName is the directory name where the .proto file is generated and // compiled. pbPkgName = "pb" -) - -const ( // validateServer generates the validation code for request messages in the // server package. validateServer validateKind = iota + 1 // validateClient generates the validation code for response messages in the // client package. validateClient - // validateBoth generates the validation code in both server and client - // packages. - validateBoth ) -// Get retrieves the transport data for the service with the given name -// computing it if needed. It returns nil if there is no service with the given -// name. +// newServicesData builds the values passed to gRPC client and server templates +// from the saved service data and gRPC plan. +func newServicesData(services *service.ServicesData, plan *Plan) *ServicesData { + if services.Root != plan.root { + panic(fmt.Sprintf("gRPC service data does not belong to design %q", plan.root.API.Name)) + } + data := &ServicesData{ + ServicesData: services, + GRPCServices: make(map[string]*ServiceData), + cliPlan: plan.cli, + protobuf: plan.protobuf, + tools: plan.tools, + symbols: plan.symbols, + servicePlans: append([]*grpcServicePlan(nil), plan.servicesPlan...), + serviceByExpr: make(map[*expr.GRPCServiceExpr]*ServiceData, len(plan.servicesPlan)), + endpointPlans: make(map[*expr.GRPCEndpointExpr]*grpcEndpointPlan), + metadataPlans: make(map[*expr.MappedAttributeExpr][]*grpcMetadataPlan), + generation: plan.generation, + } + data.expressions = make([]*expr.GRPCServiceExpr, len(data.servicePlans)) + for index, servicePlan := range data.servicePlans { + data.expressions[index] = servicePlan.expression + for _, endpointPlan := range servicePlan.endpoints { + data.endpointPlans[endpointPlan.expression] = endpointPlan + for mapped, metadata := range endpointPlan.metadata { + data.metadataPlans[mapped] = metadata + } + } + serviceData := data.analyze(servicePlan) + data.GRPCServices[servicePlan.expression.Name()] = serviceData + data.serviceByExpr[servicePlan.source] = serviceData + } + return data +} + +// Get retrieves the transport data saved for the service with the given name. +// It returns nil if there is no service with the given name. func (d *ServicesData) Get(name string) *ServiceData { - if data, ok := d.GRPCServices[name]; ok { - return data + return d.GRPCServices[name] +} + +// exampleServiceData copies the package qualifiers used by one executable. +// Server examples import the service, protobuf, and gRPC server packages; +// command-line examples import the service package only when they receive a +// result stream. +func (d *ServicesData) exampleServiceData(source *ServiceData, outputPackage string, server bool) *ServiceData { + data := *source + service := *source.Service + data.Service = &service + data.Endpoints = make([]*EndpointData, len(source.Endpoints)) + for index, endpoint := range source.Endpoints { + copy := *endpoint + data.Endpoints[index] = © + } + if server { + service.PkgName = d.ServiceImport(outputPackage, service.Name).Name + protobufPath := path.Join(d.GenPkg(), "grpc", service.PathName, pbPkgName) + data.PkgName = d.PackageImport(outputPackage, protobufPath).Name + data.ServerPkgName = d.PackageImport(outputPackage, path.Join(d.GenPkg(), "grpc", service.PathName, "server")).Name + for _, endpoint := range data.Endpoints { + endpoint.PkgName = data.PkgName + endpoint.ServicePkgName = service.PkgName + } + return &data } - service := d.Root.API.GRPC.Service(name) - if service == nil { - return nil + if grpcServiceStreamsResult(d.servicePlan(service.Name).expression) { + service.PkgName = d.ServiceImport(outputPackage, service.Name).Name + for _, endpoint := range data.Endpoints { + endpoint.ServicePkgName = service.PkgName + } } - d.GRPCServices[name] = d.analyze(service) - return d.GRPCServices[name] + return &data +} + +// servicePlan returns the copied gRPC plan for service name. +func (d *ServicesData) servicePlan(name string) *grpcServicePlan { + for _, plan := range d.servicePlans { + if plan.expression.Name() == name { + return plan + } + } + panic(fmt.Sprintf("gRPC service plan %q is missing", name)) } // Endpoint returns the endpoint data for the endpoint with the given name, nil @@ -488,171 +768,242 @@ func (sd *ServiceData) HasStreamingEndpoint() bool { return false } +// serviceHasViewedResult reports whether any generated transport section +// references the service views package through a viewed method result. +func serviceHasViewedResult(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + if endpoint.Method.ViewedResult != nil { + return true + } + } + return false +} + +// serviceHasUnaryViewedResult reports whether client/encode_decode.go emits a +// response decoder that constructs and validates a viewed unary result. +func serviceHasUnaryViewedResult(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + if endpoint.ClientStream == nil && endpoint.Method.ViewedResult != nil { + return true + } + } + return false +} + +// serviceHasViewedClientStream reports whether client.go emits a receive +// method that constructs and validates a viewed streaming result. +func serviceHasViewedClientStream(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + if endpoint.ClientStream != nil && + endpoint.ClientStream.RecvConvert != nil && + endpoint.Method.ViewedResult != nil { + return true + } + } + return false +} + +// serviceHasCallerSelectedViewedServerStream reports whether server.go sends +// the selected result view in the stream response metadata. +func serviceHasCallerSelectedViewedServerStream(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + if endpoint.ServerStream != nil && + endpoint.ServerStream.SendConvert != nil && + endpoint.Method.ViewedResult != nil && + endpoint.Method.ViewedResult.ViewName == "" { + return true + } + } + return false +} + // analyze creates the data necessary to render the code of the given service. -func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { +func (d *ServicesData) analyze(servicePlan *grpcServicePlan) *ServiceData { + gs := servicePlan.expression svc := d.ServicesData.Get(gs.Name()) - scope := codegen.NewNameScope() - pkg := codegen.SnakeCase(codegen.Goify(svc.Name, false)) + pbPkgName - svcVarN := scope.HashedUnique(gs.ServiceExpr, codegen.Goify(svc.Name, true)) + transportService := *svc + transportService.ProtoImports = append([]*codegen.ImportSpec(nil), svc.ProtoImports...) + transportService.ProtoImports = append(transportService.ProtoImports, servicePlan.protoGoImports...) + clientPackage := path.Join(d.GenPkg(), "grpc", svc.PathName, "client") + serverPackage := path.Join(d.GenPkg(), "grpc", svc.PathName, "server") + clientServicePackage := d.ServiceImport(clientPackage, svc.Name).Name + serverServicePackage := d.ServiceImport(serverPackage, svc.Name).Name + transportService.PkgName = clientServicePackage + svc = &transportService + protobufPath := path.Join(d.GenPkg(), "grpc", svc.PathName, pbPkgName) + clientProtobufPackage := d.PackageImport(clientPackage, protobufPath).Name + serverProtobufPackage := d.PackageImport(serverPackage, protobufPath).Name + planned := d.protobuf[gs] + if planned == nil { + panic(fmt.Sprintf("protobuf plan is missing for gRPC service %q", gs.Name())) + } + serviceDescriptor := planned.serviceFullName() + symbols := d.symbols[gs] + if symbols == nil { + panic(fmt.Sprintf("Go names are missing for gRPC service %q", gs.Name())) + } sd := &ServiceData{ - Service: svc, - Name: svcVarN, - Description: svc.Description, - PkgName: pkg, - ServerStruct: "Server", - ClientStruct: "Client", - ServerInit: "New", - ClientInit: "NewClient", - ServerInterface: svcVarN + "Server", - ClientInterface: svcVarN + "Client", - ClientInterfaceInit: fmt.Sprintf("%s.New%sClient", pkg, svcVarN), - Scope: scope, - } - seen, imported := make(map[string]struct{}), make(map[string]struct{}) - for _, e := range gs.GRPCEndpoints { - hasRequestMessage := !isEmpty(e.Request.Type) - useStreamEnvelope := usesStreamEnvelope(e) - - // Derive protocol buffer shaped copies of the request and response - // attributes. The design expressions are inputs to the analysis and - // must not be mutated: the shaped attributes are kept in locals and - // threaded explicitly to the data builders below. - requestMessage := makeProtoBufMessage(e.Request, protoBufify(e.Name()+"_request", true, true), sd) - streamingRequest := e.StreamingRequest - if e.MethodExpr.StreamingPayload.Type != expr.Empty { - streamMessageName := protoBufify(e.Name()+"_streaming_request", true, true) - if useStreamEnvelope { - streamMessageName = protoBufify(e.Name()+"_stream_item", true, true) - } - streamingRequest = makeProtoBufMessage(e.StreamingRequest, streamMessageName, sd) + Service: svc, + Name: planned.serviceName, + Description: svc.Description, + PkgName: clientProtobufPackage, + ClientProtobufPkgName: clientProtobufPackage, + ServerProtobufPkgName: serverProtobufPackage, + ClientServicePkgName: clientServicePackage, + ServerServicePkgName: serverServicePackage, + ProtoImports: append([]string(nil), servicePlan.protoImports...), + ServerStruct: symbols.serverStruct.Name(), + ServerStructDeclaration: symbols.serverStruct, + ClientStruct: symbols.clientStruct.Name(), + ClientStructDeclaration: symbols.clientStruct, + ServerInit: symbols.serverInit.Name(), + ServerInitDeclaration: symbols.serverInit, + ClientInit: symbols.clientInit.Name(), + ClientInitDeclaration: symbols.clientInit, + ServerInterface: planned.name(serviceDescriptor, protocServiceServerName), + ClientInterface: planned.name(serviceDescriptor, protocServiceClientName), + ClientInterfaceInit: clientProtobufPackage + "." + planned.name(serviceDescriptor, protocServiceClientConstructorName), + UnimplementedServer: planned.name(serviceDescriptor, protocServiceUnimplementedServerName), + RegisterFunction: planned.name(serviceDescriptor, protocServiceRegisterName), + Scope: servicePlan.scope, + protobuf: planned.catalog, + } + sd.protobuf.packageName = clientProtobufPackage + finishProtobufPackage(sd) + protobufMessages := planned.messages + for index, e := range gs.GRPCEndpoints { + endpointPlan := servicePlan.endpointByExpr[e] + if endpointPlan == nil { + panic(fmt.Sprintf("saved gRPC endpoint data is missing for %q", e.Name())) } - var requestEnvelope *expr.AttributeExpr - if useStreamEnvelope { - requestEnvelope = makeProtoBufStreamEnvelope( - requestMessage, - streamingRequest, - protoBufify(e.Name()+"_streaming_request", true, true), - sd, - ) + endpointSymbols := symbols.endpoints[e] + if endpointSymbols == nil { + panic(fmt.Sprintf("Go names are missing for gRPC endpoint %q", e.Name())) } - responseMessage := makeProtoBufMessage(e.Response.Message, protoBufify(e.Name()+"_response", true, true), sd) - errorMessages := make(map[string]*expr.AttributeExpr, len(e.GRPCErrors)) - for _, er := range e.GRPCErrors { - if er.Type == expr.ErrorResult || !expr.IsObject(er.Type) { - continue - } - errorMessages[er.Name] = makeProtoBufMessage(er.Response.Message, protoBufify(e.Name()+"_"+er.Name+"_error", true, true), sd) - } - - // collect all the nested messages and return the top-level message - // Also collect all proto imports specified via Meta. - collect := func(att *expr.AttributeExpr) *service.UserTypeData { - msgs, imports := collectMessages(att, sd, seen) - if len(imports) > 0 { - for _, imp := range imports { - if _, ok := imported[imp]; ok { - continue - } - imported[imp] = struct{}{} - sd.ProtoImports = append(sd.ProtoImports, imp) - } - } - if len(msgs) > 0 { - sd.Messages = append(sd.Messages, msgs...) - return msgs[0] - } - // lookup message in sd.Messages - if ut, ok := att.Type.(expr.UserType); ok { - name := ut.Name() - if n := att.Meta["struct:name:proto"]; n != nil { - name = n[0] - } - for _, t := range sd.Messages { - if t.Name == name { - return t - } - } + hasRequestMessage := !isEmpty(e.Request.Type) + messages := protobufMessages[index] + requestMessage := messages.request + streamingRequest := messages.streamingRequest + requestEnvelope := messages.requestEnvelope + responseMessage := messages.response + errorMessages := messages.errors + collect := func(attribute *expr.AttributeExpr) *protobufMessageRecord { + record := sd.protobuf.message(attribute) + if record == nil || record.data == nil { + panic(fmt.Sprintf("no protobuf message collected for attribute of type %q", attribute.Type.Name())) // bug } - panic(fmt.Sprintf("no protobuf message collected for attribute of type %q", att.Type.Name())) // bug + return record } var ( - payloadRef string - resultRef string - viewedResultRef string + clientPayloadRef string + serverPayloadRef string + clientResultRef string + serverResultRef string + viewedResultRef string ) md := svc.Method(e.Name()) if e.MethodExpr.Payload.Type != expr.Empty { - payloadRef = svc.Scope.GoFullTypeRef(e.MethodExpr.Payload, - md.PayloadLoc.PackageNameOrDefault(svc.PkgName)) + clientContext := d.serviceTypeContext(sd, "client").Enter(e.MethodExpr.Payload) + serverContext := d.serviceTypeContext(sd, "server").Enter(e.MethodExpr.Payload) + clientPayloadRef = clientContext.Scope.Ref(e.MethodExpr.Payload, clientContext.Pkg(e.MethodExpr.Payload)) + serverPayloadRef = serverContext.Scope.Ref(e.MethodExpr.Payload, serverContext.Pkg(e.MethodExpr.Payload)) } if e.MethodExpr.Result.Type != expr.Empty { - resultRef = svc.Scope.GoFullTypeRef(e.MethodExpr.Result, - md.ResultLoc.PackageNameOrDefault(svc.PkgName)) + clientContext := d.serviceTypeContext(sd, "client").Enter(e.MethodExpr.Result) + serverContext := d.serviceTypeContext(sd, "server").Enter(e.MethodExpr.Result) + clientResultRef = clientContext.Scope.Ref(e.MethodExpr.Result, clientContext.Pkg(e.MethodExpr.Result)) + serverResultRef = serverContext.Scope.Ref(e.MethodExpr.Result, serverContext.Pkg(e.MethodExpr.Result)) } if md.ViewedResult != nil { viewedResultRef = md.ViewedResult.FullRef } errors := d.buildErrorsData(e, errorMessages, sd) - for _, er := range e.GRPCErrors { - if er.Type == expr.ErrorResult || !expr.IsObject(er.Type) { - continue - } - collect(errorMessages[er.Name]) - } - // build request data - reqMD := extractMetadata(e.Metadata, e.MethodExpr.Payload, svc.Scope, *d) + payloadIdentity := expr.MethodPayloadExampleIdentity(e.MethodExpr) + resultIdentity := expr.MethodResultExampleIdentity(e.MethodExpr) + reqMD := d.extractMetadata(e.Metadata, e.MethodExpr.Payload, sd, "server", "v", payloadIdentity) request := &RequestData{ Description: requestMessage.Description, Metadata: reqMD, ServerConvert: d.buildRequestConvertData(requestMessage, e.MethodExpr.Payload, reqMD, e, sd, true), ClientConvert: d.buildRequestConvertData(requestMessage, e.MethodExpr.Payload, reqMD, e, sd, false), } + if e.MethodExpr.Payload.Type != expr.Empty { + request.CLIInitCode = d.buildCLIRequestTransform(e, sd) + } if hasRequestMessage { - request.PayloadMessage = collect(requestMessage) + request.PayloadMessage = collect(requestMessage).data + request.ServerPayloadMessageRef = protoBufGoFullTypeRef(requestMessage, sd.ServerProtobufPkgName, sd) } if obj := expr.AsObject(requestMessage.Type); (obj != nil && len(*obj) > 0) || expr.IsUnion(requestMessage.Type) { // add the request message as the first argument to the CLI + typeName := protoBufGoFullTypeName(requestMessage, sd.PkgName, sd) request.CLIArgs = append(request.CLIArgs, &InitArgData{ Name: "message", Ref: "message", - TypeName: protoBufGoFullTypeName(requestMessage, sd.PkgName, sd.Scope), - TypeRef: protoBufGoFullTypeRef(requestMessage, sd.PkgName, sd.Scope), - Example: requestMessage.Example(d.Root.API.ExampleGenerator), + TypeName: typeName, + TypeRef: protoBufGoFullTypeRef(requestMessage, sd.PkgName, sd), + CLIPlan: cli.NewProtobufFlagPlan(requestMessage, typeName), + Example: protobufCLIExample(requestMessage, d.Example(requestMessage, payloadIdentity), sd.protobuf.plan), }) } // pass the metadata as arguments to client CLI args - request.CLIArgs = append(request.CLIArgs, initArgsFromMetadata(reqMD)...) + request.CLIArgs = append(request.CLIArgs, argsFromMetadata(reqMD)...) + transportRequest := requestMessage switch { case requestEnvelope != nil: - request.Message = collect(requestEnvelope) - request.StreamEnvelope = buildStreamEnvelopeData(requestEnvelope, request.Message, sd) - if e.LegacyStreamCompat() { + transportRequest = requestEnvelope + record := collect(requestEnvelope) + request.Message = record.data + request.ProtoMessageName = record.protoName + request.StreamEnvelope = buildStreamEnvelopeData(requestEnvelope, sd) + if endpointPlan.legacyStream { request.LegacyDecode = d.buildLegacyDecodeData(e, sd) } case streamingRequest.Type != expr.Empty: - request.Message = collect(streamingRequest) + transportRequest = streamingRequest + record := collect(streamingRequest) + request.Message = record.data + request.ProtoMessageName = record.protoName default: - request.Message = collect(requestMessage) + record := collect(requestMessage) + request.Message = record.data + request.ProtoMessageName = record.protoName } + request.ClientMessageRef = protoBufGoFullTypeRef(transportRequest, sd.ClientProtobufPkgName, sd) + request.ServerMessageRef = protoBufGoFullTypeRef(transportRequest, sd.ServerProtobufPkgName, sd) // build response data - result, svcCtx := resultContext(e, sd) - hdrs := extractMetadata(e.Response.Headers, result, svc.Scope, *d) - trlrs := extractMetadata(e.Response.Trailers, result, svc.Scope, *d) + serverResult, serverCtx := d.resultContext(e, sd, "server") + clientResult, clientCtx := d.resultContext(e, sd, "client") + hdrs := d.extractMetadata(e.Response.Headers, clientResult, sd, "client", "result", resultIdentity) + trlrs := d.extractMetadata(e.Response.Trailers, clientResult, sd, "client", "result", resultIdentity) + serverConverts := d.buildServerResponseConverts(responseMessage, serverResult, serverCtx, e, sd) + clientConverts := d.buildClientResponseConverts(responseMessage, clientResult, clientCtx, hdrs, trlrs, e, sd) + var viewedServerConverts, viewedClientConverts []*ViewConvertData + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + viewedServerConverts = serverConverts + viewedClientConverts = clientConverts + } response := &ResponseData{ - StatusCode: statusCodeToGRPCConst(e.Response.StatusCode), - Description: e.Response.Description, - Headers: hdrs, - Trailers: trlrs, - ServerConvert: d.buildResponseConvertData(responseMessage, result, svcCtx, hdrs, trlrs, e, sd, true), - ClientConvert: d.buildResponseConvertData(responseMessage, result, svcCtx, hdrs, trlrs, e, sd, false), + StatusCode: statusCodeToGRPCConst(e.Response.StatusCode), + Description: e.Response.Description, + Headers: hdrs, + Trailers: trlrs, + ServerConvert: primaryViewConvert(serverConverts), + ServerConverts: viewedServerConverts, + ClientConvert: primaryViewConvert(clientConverts), + ClientConverts: viewedClientConverts, } // If the endpoint is a streaming endpoint, no message is returned // by gRPC. Hence, no need to set response message. if responseMessage.Type != expr.Empty || !e.MethodExpr.IsStreaming() { - response.Message = collect(responseMessage) + record := collect(responseMessage) + response.Message = record.data + response.ProtoMessageName = record.protoName + response.ClientMessageRef = protoBufGoFullTypeRef(responseMessage, sd.ClientProtobufPkgName, sd) + response.ServerMessageRef = protoBufGoFullTypeRef(responseMessage, sd.ServerProtobufPkgName, sd) } // gather security requirements @@ -673,251 +1024,241 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { } } ed := &EndpointData{ - ServiceName: svc.Name, - PkgName: sd.PkgName, - ServicePkgName: svc.PkgName, - Method: md, - PayloadType: e.MethodExpr.Payload.Type, - PayloadRef: payloadRef, - ResultRef: resultRef, - ViewedResultRef: viewedResultRef, - Request: request, - Response: response, - MessageSchemes: msgSch, - MetadataSchemes: metSch, - Errors: errors, - ServerStruct: sd.ServerStruct, - ServerInterface: sd.ServerInterface, - ClientMethodName: protoBufify(md.VarName, true, true), - ClientStruct: sd.ClientStruct, - ClientInterface: sd.ClientInterface, + ServiceName: svc.Name, + PkgName: sd.PkgName, + ServicePkgName: svc.PkgName, + ClientProtobufPkgName: sd.ClientProtobufPkgName, + ServerProtobufPkgName: sd.ServerProtobufPkgName, + ClientServicePkgName: sd.ClientServicePkgName, + ServerServicePkgName: sd.ServerServicePkgName, + Method: md, + ProtoMethodName: planned.methods[e], + ClientMethodName: planned.methods[e], + FullMethodName: planned.serviceFullName() + "/" + planned.methods[e], + PayloadType: e.MethodExpr.Payload.Type, + PayloadRef: clientPayloadRef, + ClientPayloadRef: clientPayloadRef, + ServerPayloadRef: serverPayloadRef, + ResultRef: clientResultRef, + ClientResultRef: clientResultRef, + ServerResultRef: serverResultRef, + ViewedResultRef: viewedResultRef, + Request: request, + Response: response, + MessageSchemes: msgSch, + MetadataSchemes: metSch, + Errors: errors, + ServerStruct: sd.ServerStruct, + ServerStructDeclaration: sd.ServerStructDeclaration, + ServerInterface: sd.ServerInterface, + GRPCMethodName: planned.name(serviceDescriptor+"."+planned.methods[e], protocMethodName), + ClientStruct: sd.ClientStruct, + ClientStructDeclaration: sd.ClientStructDeclaration, + ClientInterface: sd.ClientInterface, + } + ed.ClientBuild = endpointSymbols.clientBuild.Name() + ed.ClientBuildDeclaration = endpointSymbols.clientBuild + if endpointSymbols.clientEncode != nil { + ed.ClientEncode = endpointSymbols.clientEncode.Name() + ed.ClientEncodeDeclaration = endpointSymbols.clientEncode } + if endpointSymbols.clientDecode != nil { + ed.ClientDecode = endpointSymbols.clientDecode.Name() + ed.ClientDecodeDeclaration = endpointSymbols.clientDecode + } + ed.ServerHandler = endpointSymbols.serverHandler.Name() + ed.ServerHandlerDeclaration = endpointSymbols.serverHandler + if endpointSymbols.serverDecode != nil { + ed.ServerDecode = endpointSymbols.serverDecode.Name() + ed.ServerDecodeDeclaration = endpointSymbols.serverDecode + } + ed.ServerEncode = endpointSymbols.serverEncode.Name() + ed.ServerEncodeDeclaration = endpointSymbols.serverEncode sd.Endpoints = append(sd.Endpoints, ed) if e.MethodExpr.IsStreaming() { ed.ServerStream = d.buildStreamData(e, streamingRequest, responseMessage, sd, true) + ed.ServerStream.VarName = endpointSymbols.serverStream.Name() + ed.ServerStream.Declaration = endpointSymbols.serverStream ed.ClientStream = d.buildStreamData(e, streamingRequest, responseMessage, sd, false) + ed.ClientStream.VarName = endpointSymbols.clientStream.Name() + ed.ClientStream.Declaration = endpointSymbols.clientStream } } return sd } -// collectMessages recurses through the attribute to gather all the messages. -func collectMessages(at *expr.AttributeExpr, sd *ServiceData, seen map[string]struct{}) (data []*service.UserTypeData, imports []string) { - if at == nil { - return data, imports - } - if proto := at.Meta["struct:field:proto"]; len(proto) > 1 { - imp := proto[1] - found := false - for _, i := range sd.Service.ProtoImports { - if i.Path == imp { - found = true - break +// collectProtobufPackage copies each method message and records every message +// and oneof before generated Go names are fixed. +func collectProtobufPackage(serviceExpr *expr.GRPCServiceExpr, catalog *protobufPackageCatalog) ([]*protobufEndpointMessages, error) { + prepared := make([]*protobufEndpointMessages, len(serviceExpr.GRPCEndpoints)) + for index, endpoint := range serviceExpr.GRPCEndpoints { + useStreamEnvelope := usesStreamEnvelope(endpoint) + request := makeProtoBufMessage( + endpoint.Request, + codegen.ProtobufName(endpoint.Name()+"_request"), + expr.GRPCRequestMessageExampleIdentity(endpoint.MethodExpr), + ) + streamingRequest := endpoint.StreamingRequest + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + name := codegen.ProtobufName(endpoint.Name() + "_streaming_request") + if useStreamEnvelope { + name = codegen.ProtobufName(endpoint.Name() + "_stream_item") } + streamingRequest = makeProtoBufMessage( + endpoint.StreamingRequest, + name, + expr.GRPCStreamingRequestMessageExampleIdentity(endpoint.MethodExpr), + ) + } + var requestEnvelope *expr.AttributeExpr + if useStreamEnvelope { + requestEnvelope = makeProtoBufStreamEnvelope( + request, + streamingRequest, + codegen.ProtobufName(endpoint.Name()+"_streaming_request"), + expr.GRPCStreamingRequestMessageExampleIdentity(endpoint.MethodExpr), + ) + } + responseOwner := expr.GRPCResponseMessageExampleIdentity(endpoint.MethodExpr) + if endpoint.MethodExpr.IsResultStreaming() { + responseOwner = expr.GRPCStreamingResponseMessageExampleIdentity(endpoint.MethodExpr) } - if !found { - imports = append(imports, imp) - if len(proto) > 3 { - elems := strings.Split(proto[3], "/") - sd.Service.ProtoImports = append(sd.Service.ProtoImports, &codegen.ImportSpec{Path: proto[3], Name: elems[len(elems)-1]}) + response := makeProtoBufMessage( + endpoint.Response.Message, + codegen.ProtobufName(endpoint.Name()+"_response"), + responseOwner, + ) + errors := make(map[string]*expr.AttributeExpr, len(endpoint.GRPCErrors)) + for _, grpcError := range endpoint.GRPCErrors { + if expr.IsErrorResult(grpcError.Type) || !expr.IsObject(grpcError.Type) { + continue } + errors[grpcError.Name] = makeProtoBufMessage( + grpcError.Response.Message, + codegen.ProtobufName(endpoint.Name()+"_"+grpcError.Name+"_error"), + expr.GRPCErrorMessageExampleIdentity(endpoint.MethodExpr, grpcError.ErrorExpr), + ) + } + prepared[index] = &protobufEndpointMessages{ + request: request, + streamingRequest: streamingRequest, + requestEnvelope: requestEnvelope, + response: response, + errors: errors, } } - if expr.IsPrimitive(at.Type) { - // Add google.protobuf.Value import when Any type is used - if at.Type.Kind() == expr.AnyKind { - found := false - for _, imp := range imports { - if imp == "google/protobuf/struct.proto" { - found = true - break - } + + collect := catalog.collectMessage + for index, endpoint := range serviceExpr.GRPCEndpoints { + messages := prepared[index] + requestSource := protobufRootMessageSource(endpoint.Request, endpoint, nil, protobufRequestMessage) + streamingSource := protobufRootMessageSource(endpoint.StreamingRequest, endpoint, nil, protobufStreamingRequestMessage) + responseSource := protobufRootMessageSource(endpoint.Response.Message, endpoint, nil, protobufResponseMessage) + catalog.bindRootSource(messages.request, requestSource) + if messages.streamingRequest.Type != expr.Empty { + catalog.bindRootSource(messages.streamingRequest, streamingSource) + } + catalog.bindRootSource(messages.response, responseSource) + for _, grpcError := range endpoint.GRPCErrors { + message := messages.errors[grpcError.Name] + if message == nil { + continue } - if !found { - imports = append(imports, "google/protobuf/struct.proto") + errorSource := protobufRootMessageSource( + grpcError.Response.Message, + endpoint, + grpcError, + protobufErrorMessage, + ) + catalog.bindRootSource(message, errorSource) + if err := collect(message, errorSource); err != nil { + return nil, err + } + } + requestNeeded := !isEmpty(endpoint.Request.Type) || + (messages.requestEnvelope == nil && messages.streamingRequest.Type == expr.Empty) + if requestNeeded { + if err := collect(messages.request, requestSource); err != nil { + return nil, err + } + } + if messages.requestEnvelope != nil { + envelopeSource := protobufMessageSource{synthetic: protobufSyntheticMessage{ + endpoint: endpoint, + role: protobufStreamEnvelopeMessage, + }} + catalog.bindRootSource(messages.requestEnvelope, envelopeSource) + if err := collect(messages.requestEnvelope, envelopeSource); err != nil { + return nil, err + } + } + if messages.streamingRequest.Type != expr.Empty { + if err := collect(messages.streamingRequest, streamingSource); err != nil { + return nil, err + } + } + if messages.response.Type != expr.Empty || !endpoint.MethodExpr.IsStreaming() { + if err := collect(messages.response, responseSource); err != nil { + return nil, err } } - return data, imports - } - collect := func(at *expr.AttributeExpr) ([]*service.UserTypeData, []string) { - return collectMessages(at, sd, seen) } - switch dt := at.Type.(type) { - case expr.UserType: - name := dt.Name() - if n := at.Meta["struct:name:proto"]; n != nil { - name = n[0] - } - if _, ok := seen[name]; ok { - return data, imports - } - att := userTypeAttribute(dt) - data = append(data, &service.UserTypeData{ - Name: name, - VarName: protoBufMessageName(at, sd.Scope), - Description: dt.Attribute().Description, - Def: protoBufMessageDef(att, sd), - Ref: protoBufGoFullTypeRef(at, sd.PkgName, sd.Scope), - Type: dt, - }) - seen[name] = struct{}{} - d, i := collect(att) - data = append(data, d...) - imports = append(imports, i...) - case *expr.Object: - for _, nat := range *dt { - d, i := collect(nat.Attribute) - data = append(data, d...) - imports = append(imports, i...) + return prepared, nil +} + +// finishProtobufPackage reads the final Go names and builds the message and +// validation data used by generated clients and servers. +func finishProtobufPackage(sd *ServiceData) { + sd.protobuf.freezeMessages(sd) + sd.Messages = sd.protobuf.protoMessageData() + + sd.validations = sd.protobuf.freezeValidations(sd) +} + +// protobufRootMessageSource connects a root message to the authored service +// declaration whose value it carries. The endpoint value is used only when +// the service value is inline or created by Goa. An explicit Message DSL may +// provide the declaration instead. +func protobufRootMessageSource(attribute *expr.AttributeExpr, endpoint *expr.GRPCEndpointExpr, grpcError *expr.GRPCErrorExpr, role protobufSyntheticRole) protobufMessageSource { + var serviceAttribute *expr.AttributeExpr + switch role { + case protobufRequestMessage: + serviceAttribute = endpoint.MethodExpr.Payload + case protobufStreamingRequestMessage: + serviceAttribute = endpoint.MethodExpr.StreamingPayload + case protobufResponseMessage: + serviceAttribute = endpoint.MethodExpr.Result + case protobufErrorMessage: + if methodError := endpoint.MethodExpr.Error(grpcError.Name); methodError != nil { + serviceAttribute = methodError.AttributeExpr } - case *expr.Array: - d, i := collect(dt.ElemType) - data = append(data, d...) - imports = append(imports, i...) - case *expr.Map: - dk, ik := collect(dt.KeyType) - data = append(data, dk...) - imports = append(imports, ik...) - de, ie := collect(dt.ElemType) - data = append(data, de...) - imports = append(imports, ie...) - case *expr.Union: - for _, nat := range dt.Values { - d, i := collect(nat.Attribute) - data = append(data, d...) - imports = append(imports, i...) + } + if serviceAttribute != nil { + if userType, ok := serviceAttribute.Type.(expr.UserType); ok { + return protobufMessageSource{origin: userType.Origin()} } } - return data, imports + if userType, ok := attribute.Type.(expr.UserType); ok { + return protobufMessageSource{origin: userType.Origin()} + } + return protobufMessageSource{synthetic: protobufSyntheticMessage{ + endpoint: endpoint, + error: grpcError, + role: role, + }} } -// addValidation adds a validation function (if any) for the given user type -// and recurses through the user type adding other validation functions -// (if any). +// addValidation returns the validation function chosen for the given protobuf +// message in the generated server or client package. // // req if true indicates that the validation is generated for validating // request (server-side) messages. -func addValidation(att *expr.AttributeExpr, attName string, sd *ServiceData, req bool) *ValidationData { - ut, ok := att.Type.(expr.UserType) - if !ok { - return nil - } - vtx := protoBufTypeContext(sd.PkgName, sd.Scope, false) - // Validation helper names must be derived from the same protobuf-aware - // scope used by the validation templates so that function declarations - // and call sites (e.g. Message_) stay in sync regardless of traversal - // order or reserved-name handling. - name := vtx.Scope.Name(att, "", vtx.Pointer, vtx.UseDefault) - ref := protoBufGoFullTypeRef(att, sd.PkgName, sd.Scope) +func addValidation(att *expr.AttributeExpr, sd *ServiceData, req bool) *ValidationData { kind := validateClient if req { kind = validateServer } - att = userTypeAttribute(ut) - for _, n := range sd.validations { - if n.SrcName == name { - if n.Kind != kind { - n.Kind = validateBoth - collectValidations(att, attName, req, sd) - } - return n - } - } - removeMeta(att) - if def := codegen.ValidationCode(att, ut, vtx, true, expr.IsAlias(att.Type), false, attName); def != "" { - v := &ValidationData{ - // Validation function names must match the identifiers used by - // validation templates. The template uses the scoped type name - // directly (no Goify) to preserve proto-reserved names like Message_. - Name: "Validate" + name, - Def: def, - ArgName: attName, - SrcName: name, - SrcRef: ref, - Kind: kind, - } - sd.validations = append(sd.validations, v) - collectValidations(att, attName, req, sd) - return v - } - return nil -} - -// collectValidations recurses through the attribute and collects the -// validation functions. -// -// req if true indicates that the validations are generated for validating -// request messages. -func collectValidations(att *expr.AttributeExpr, attName string, req bool, sd *ServiceData) { - collectValidationsR(att, attName, req, sd, make(map[string]struct{})) -} - -// collectValidationsR recurses through the attribute and collects validation -// functions with cycle detection using a seen set of user type IDs. -func collectValidationsR(att *expr.AttributeExpr, attName string, req bool, sd *ServiceData, seen map[string]struct{}) { - gattName := codegen.Goify(attName, false) - switch dt := att.Type.(type) { - case expr.UserType: - if expr.IsPrimitive(dt) { - // Alias type - validation is generate inline in parent type validation code. - return - } - // Cycle guard: avoid infinite recursion on recursive user types. - if id := dt.ID(); id != "" { - if _, ok := seen[id]; ok { - return - } - seen[id] = struct{}{} - } - vtx := protoBufTypeContext(sd.PkgName, sd.Scope, false) - def := codegen.AttributeValidationCode(att, dt, vtx, true, false, gattName, attName) - // Match helper function identifiers with validation template calls by - // using the same protobuf-aware scope for the type name. This keeps - // names like Message_ consistent between declarations and call sites. - name := vtx.Scope.Name(att, "", vtx.Pointer, vtx.UseDefault) - kind := validateClient - if req { - kind = validateServer - } - for _, n := range sd.validations { - if n.SrcName == name { - if n.Kind != validateBoth && n.Kind != kind { - n.Kind = validateBoth - goto collect - } - return - } - } - if def != "" { - sd.validations = append(sd.validations, &ValidationData{ - // Match helper function identifiers with validation template - // calls. The template uses the scoped type name directly (no - // Goify) to preserve proto-reserved names like Message_. - Name: "Validate" + name, - Def: def, - ArgName: gattName, - SrcName: name, - SrcRef: protoBufGoFullTypeRef(att, sd.PkgName, sd.Scope), - Kind: kind, - }) - } - collect: - att := userTypeAttribute(dt) - collectValidationsR(att, attName, req, sd, seen) - case *expr.Object: - for _, nat := range *dt { - collectValidationsR(nat.Attribute, nat.Name, req, sd, seen) - } - case *expr.Array: - collectValidationsR(dt.ElemType, "elem", req, sd, seen) - case *expr.Map: - collectValidationsR(dt.KeyType, "key", req, sd, seen) - collectValidationsR(dt.ElemType, "val", req, sd, seen) - case *expr.Union: - for _, nat := range dt.Values { - collectValidationsR(nat.Attribute, nat.Name, req, sd, seen) - } - } + return sd.protobuf.validation(att, kind) } // userTypeAttribute returns the attribute of the given user type. @@ -939,6 +1280,108 @@ func userTypeAttribute(ut expr.UserType) *expr.AttributeExpr { return att } +// protobufCLIExample writes object field names exactly as they appear in the +// protobuf file. For example, a Goa field named "tenantID" is shown as +// "tenant_id" in command help. +func protobufCLIExample(attribute *expr.AttributeExpr, value any, plan *protobufServicePlan) any { + return protobufCLIExampleValue(attribute, value, plan, false) +} + +// protobufCLIExampleValue avoids adding the same protobuf object twice while +// visiting its field. Values inside arrays and maps are converted separately. +func protobufCLIExampleValue(attribute *expr.AttributeExpr, value any, plan *protobufServicePlan, skipWrapper bool) any { + if value == nil { + return nil + } + if !skipWrapper && isWrappedAttr(attribute) { + field := unwrapAttr(attribute) + fieldValue := value + if !field.Type.IsCompatible(value) { + var ok bool + fieldValue, ok = namedExampleValue(value, wrappedField) + if !ok { + panic("protobuf CLI wrapper example has no field value") + } + } + return map[string]any{ + plan.sourceFieldName(field): protobufCLIExampleValue(field, fieldValue, plan, true), + } + } + if object := expr.AsObject(attribute.Type); object != nil { + result := make(map[string]any, len(*object)) + for _, field := range *object { + fieldValue, ok := namedExampleValue(value, field.Name) + if !ok { + continue + } + if expr.AsUnion(field.Attribute.Type) != nil { + for name, branchValue := range protobufCLIExampleValue(field.Attribute, fieldValue, plan, false).(map[string]any) { + result[name] = branchValue + } + continue + } + result[plan.sourceFieldName(field.Attribute)] = protobufCLIExampleValue(field.Attribute, fieldValue, plan, false) + } + return result + } + if union := expr.AsUnion(attribute.Type); union != nil { + branchName, ok := namedExampleValue(value, union.GetTypeKey()) + if !ok { + panic("protobuf CLI union example has no branch name") + } + branchValue, ok := namedExampleValue(value, union.GetValueKey()) + if !ok { + panic("protobuf CLI union example has no branch value") + } + for _, branch := range union.Values { + if branch.Name != branchName { + continue + } + return map[string]any{ + plan.sourceFieldName(branch.Attribute): protobufCLIExampleValue(branch.Attribute, branchValue, plan, false), + } + } + panic(fmt.Sprintf("protobuf CLI union example selects unknown branch %q", branchName)) + } + if array := expr.AsArray(attribute.Type); array != nil { + items := reflect.ValueOf(value) + if items.Kind() != reflect.Array && items.Kind() != reflect.Slice { + panic(fmt.Sprintf("protobuf CLI array example has type %T", value)) + } + result := make([]any, items.Len()) + for index := range items.Len() { + result[index] = protobufCLIExampleValue(array.ElemType, items.Index(index).Interface(), plan, false) + } + return result + } + if mapped := expr.AsMap(attribute.Type); mapped != nil { + entries := reflect.ValueOf(value) + if entries.Kind() != reflect.Map { + panic(fmt.Sprintf("protobuf CLI map example has type %T", value)) + } + result := make(map[string]any, entries.Len()) + for _, key := range entries.MapKeys() { + result[fmt.Sprint(key.Interface())] = protobufCLIExampleValue(mapped.ElemType, entries.MapIndex(key).Interface(), plan, false) + } + return result + } + return value +} + +// namedExampleValue returns the value stored under one Goa object or union +// field name. +func namedExampleValue(example any, name string) (any, bool) { + fields := reflect.ValueOf(example) + if fields.Kind() != reflect.Map || fields.Type().Key().Kind() != reflect.String { + panic(fmt.Sprintf("protobuf CLI object example has type %T", example)) + } + value := fields.MapIndex(reflect.ValueOf(name).Convert(fields.Type().Key())) + if !value.IsValid() { + return nil, false + } + return value.Interface(), true +} + // buildRequestConvertData builds the convert data for the server and client // requests. // - server side - converts the one-shot gRPC request message (if any) and @@ -958,34 +1401,38 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE return nil } - svc := sd.Service - pkg := svc.Method(e.MethodExpr.Name).PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcCtx := serviceTypeContext(pkg, svc.Scope) + side := "client" + protobufPackage := sd.ClientProtobufPkgName + if svr { + protobufPackage = sd.ServerProtobufPkgName + side = "server" + } + svcCtx := d.serviceTypeContext(sd, side).Enter(payload) if svr { // server side - data := d.buildInitData(request, payload, "message", "v", svcCtx, false, false, sd) - data.Name = fmt.Sprintf("New%sPayload", codegen.Goify(e.Name(), true)) - data.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request type.", data.Name, e.Name(), svc.Name) + data := d.buildInitData(request, payload, "message", "v", svcCtx, false, sd, expr.MethodPayloadExampleIdentity(e.MethodExpr), d.initDeclaration(e, true, grpcInitKey{role: grpcRequestInit})) // pass the metadata as arguments to payload constructor in server data.Args = append(data.Args, initArgsFromMetadata(md)...) - return &ConvertData{ - SrcName: protoBufGoFullTypeName(request, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(request, sd.PkgName, sd.Scope), - TgtName: svc.Scope.GoFullTypeName(payload, svcCtx.Pkg(payload)), - TgtRef: svc.Scope.GoFullTypeRef(payload, svcCtx.Pkg(payload)), - Init: data, - Validation: addValidation(request, "message", sd, true), + conversion := &ConvertData{ + TgtName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), + TgtRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), + Init: data, + } + if !e.MethodExpr.IsPayloadStreaming() || !isEmpty(e.Request.Type) { + conversion.SrcName = protoBufGoFullTypeName(request, protobufPackage, sd) + conversion.SrcRef = protoBufGoFullTypeRef(request, protobufPackage, sd) + conversion.Validation = addValidation(request, sd, true) } + return conversion } // client side - data := d.buildInitData(payload, request, "payload", "message", svcCtx, true, false, sd) - data.Description = fmt.Sprintf("%s builds the gRPC request type from the payload of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) + data := d.buildInitData(payload, request, "payload", "message", svcCtx, true, sd, expr.MethodPayloadExampleIdentity(e.MethodExpr), d.initDeclaration(e, false, grpcInitKey{role: grpcRequestInit})) return &ConvertData{ - SrcName: svc.Scope.GoFullTypeName(payload, pkg), - SrcRef: svc.Scope.GoFullTypeRef(payload, pkg), - TgtName: protoBufGoFullTypeName(request, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(request, sd.PkgName, sd.Scope), + SrcName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), + SrcRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), + TgtName: protoBufGoFullTypeName(request, sd.ClientProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(request, sd.ClientProtobufPkgName, sd), Init: data, } } @@ -997,88 +1444,109 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE // to metadata is carried under its own name and non-object payloads travel // under the reserved "goa_payload" key. func (d *ServicesData) buildLegacyDecodeData(e *expr.GRPCEndpointExpr, sd *ServiceData) *LegacyDecodeData { - svc := sd.Service payload := e.MethodExpr.Payload - legacyMD := expr.DupMappedAtt(e.Metadata) - mdObj := expr.AsObject(legacyMD.Type) - if pobj := expr.AsObject(payload.Type); pobj != nil { - for _, nat := range *pobj { - if mdObj.Attribute(nat.Name) == nil { - mdObj.Set(nat.Name, expr.DupAtt(nat.Attribute)) - } - if payload.IsRequired(nat.Name) { - legacyMD.Validation.AddRequired(nat.Name) - } - } - } else { - mdObj.Set("goa_payload", expr.DupAtt(payload)) - legacyMD.Validation.AddRequired("goa_payload") + endpointPlan := d.endpointPlans[e] + if endpointPlan == nil || endpointPlan.legacyMetadata == nil { + panic(fmt.Sprintf("saved legacy metadata is missing for gRPC endpoint %q", e.Name())) } - md := extractMetadata(legacyMD, payload, svc.Scope, *d) + owner := expr.MethodPayloadExampleIdentity(e.MethodExpr) + md := d.extractMetadata(endpointPlan.legacyMetadata, payload, sd, "server", "v", owner) + declaration := d.symbols[e.Service].endpoints[e].legacyDecode data := &LegacyDecodeData{ - FuncName: fmt.Sprintf("decode%sLegacyRequest", codegen.Goify(e.Name(), true)), - Metadata: md, + FuncName: declaration.Name(), + FuncDeclaration: declaration, + Metadata: md, } if expr.IsObject(payload.Type) { - pkg := svc.Method(e.MethodExpr.Name).PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcCtx := serviceTypeContext(pkg, svc.Scope) - init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, false, false, sd) - init.Name = fmt.Sprintf("New%sPayloadFromMetadata", codegen.Goify(e.Name(), true)) - init.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request metadata sent by legacy stream protocol clients.", init.Name, e.Name(), svc.Name) + svcCtx := d.serviceTypeContext(sd, "server").Enter(payload) + init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, false, sd, owner, d.initDeclaration(e, true, grpcInitKey{role: grpcLegacyRequestInit})) init.Args = append(init.Args, initArgsFromMetadata(md)...) data.ServerConvert = &ConvertData{ - TgtName: svc.Scope.GoFullTypeName(payload, svcCtx.Pkg(payload)), - TgtRef: svc.Scope.GoFullTypeRef(payload, svcCtx.Pkg(payload)), + TgtName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), + TgtRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), Init: init, } } return data } -// buildResponseConvertData builds the convert data for the server and client -// responses. -// - server side - converts method result type to generated gRPC response -// type in *.pb.go -// - client side - converts generated gRPC response type in *.pb.go and -// response metadata to method result type. -// -// svr param indicates that the convert data is generated for server side. -func (d *ServicesData) buildResponseConvertData(response, result *expr.AttributeExpr, svcCtx *codegen.AttributeContext, hdrs, trlrs []*MetadataData, e *expr.GRPCEndpointExpr, sd *ServiceData, svr bool) *ConvertData { - if !svr && (e.MethodExpr.IsStreaming() || isEmpty(e.MethodExpr.Result.Type)) { - return nil - } - svc := sd.Service - if svr { - // server side - data := d.buildInitData(result, response, "result", "message", svcCtx, true, false, sd) - data.Description = fmt.Sprintf("%s builds the gRPC response type from the result of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) - return &ConvertData{ - SrcName: svcCtx.Scope.Name(result, svcCtx.Pkg(result), svcCtx.Pointer, svcCtx.UseDefault), - SrcRef: svcCtx.Scope.Ref(result, svcCtx.Pkg(result)), - TgtName: protoBufGoFullTypeName(response, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(response, sd.PkgName, sd.Scope), - Init: data, +// buildServerResponseConverts builds one protobuf conversion for each result +// view the server may send. Results without views have one unnamed conversion. +func (d *ServicesData) buildServerResponseConverts(response, result *expr.AttributeExpr, svcCtx *codegen.AttributeContext, e *expr.GRPCEndpointExpr, sd *ServiceData) []*ViewConvertData { + views := []string{""} + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + views = grpcResultViews(e.MethodExpr) + } + converts := make([]*ViewConvertData, 0, len(views)) + for _, view := range views { + source := result + if view != "" { + var err error + source, err = grpcResultForView(result, view) + if err != nil { + panic(err) // bug + } } + key := grpcInitKey{role: grpcResponseInit, view: view} + converts = append(converts, &ViewConvertData{ + View: view, + Convert: d.buildServerResponseConvertData(response, source, svcCtx, e, sd, key), + }) } + return converts +} - // client side - data := d.buildInitData(response, result, "message", "result", svcCtx, false, false, sd) - data.Name = fmt.Sprintf("New%sResult", codegen.Goify(e.Name(), true)) - data.Description = fmt.Sprintf("%s builds the result type of the %q endpoint of the %q service from the gRPC response type.", data.Name, e.Name(), svc.Name) - // pass the headers as arguments to result constructor in client - data.Args = append(data.Args, initArgsFromMetadata(hdrs)...) - // pass the trailers as arguments to result constructor in client - data.Args = append(data.Args, initArgsFromMetadata(trlrs)...) +// buildServerResponseConvertData builds one protobuf response conversion from +// the fields selected during planning. +func (d *ServicesData) buildServerResponseConvertData(response, result *expr.AttributeExpr, svcCtx *codegen.AttributeContext, e *expr.GRPCEndpointExpr, sd *ServiceData, key grpcInitKey) *ConvertData { + data := d.buildInitData(result, response, "result", "message", svcCtx, true, sd, expr.MethodResultExampleIdentity(e.MethodExpr), d.initDeclaration(e, true, key)) return &ConvertData{ - SrcName: protoBufGoFullTypeName(response, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(response, sd.PkgName, sd.Scope), - TgtName: svcCtx.Scope.Name(result, svcCtx.Pkg(result), svcCtx.Pointer, svcCtx.UseDefault), - TgtRef: svcCtx.Scope.Ref(result, svcCtx.Pkg(result)), - Init: data, - Validation: addValidation(response, "message", sd, false), + SrcName: svcCtx.Scope.Name(result, svcCtx.Pkg(result), svcCtx.Pointer, svcCtx.UseDefault), + SrcRef: svcCtx.Scope.Ref(result, svcCtx.Pkg(result)), + TgtName: protoBufGoFullTypeName(response, sd.ServerProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(response, sd.ServerProtobufPkgName, sd), + Init: data, } } +// buildClientResponseConverts builds one service conversion for each result +// view the client may receive. Results without views have one unnamed +// conversion. +func (d *ServicesData) buildClientResponseConverts(response, result *expr.AttributeExpr, svcCtx *codegen.AttributeContext, hdrs, trlrs []*MetadataData, e *expr.GRPCEndpointExpr, sd *ServiceData) []*ViewConvertData { + if e.MethodExpr.IsStreaming() || isEmpty(e.MethodExpr.Result.Type) { + return nil + } + views := []string{""} + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + views = grpcResultViews(e.MethodExpr) + } + converts := make([]*ViewConvertData, 0, len(views)) + for _, view := range views { + target := result + if view != "" { + var err error + target, err = grpcResultForView(result, view) + if err != nil { + panic(err) // bug + } + } + key := grpcInitKey{role: grpcResponseInit, view: view} + data := d.buildInitData(response, target, "message", "result", svcCtx, false, sd, expr.MethodResultExampleIdentity(e.MethodExpr), d.initDeclaration(e, false, key)) + data.Args = append(data.Args, initArgsFromMetadata(hdrs)...) + data.Args = append(data.Args, initArgsFromMetadata(trlrs)...) + convert := &ConvertData{ + SrcName: protoBufGoFullTypeName(response, sd.ClientProtobufPkgName, sd), + SrcRef: protoBufGoFullTypeRef(response, sd.ClientProtobufPkgName, sd), + TgtName: svcCtx.Scope.Name(target, svcCtx.Pkg(target), svcCtx.Pointer, svcCtx.UseDefault), + TgtRef: svcCtx.Scope.Ref(target, svcCtx.Pkg(target)), + Init: data, + Validation: addValidation(response, sd, false), + } + converts = append(converts, &ViewConvertData{View: view, Convert: convert}) + } + return converts +} + // buildInitData builds the transformation code to convert source to target. // // source, target are the source and target attributes used in the @@ -1087,32 +1555,34 @@ func (d *ServicesData) buildResponseConvertData(response, result *expr.Attribute // transformation // svcCtx is the attribute context for service type // proto if true indicates the target type is a protocol buffer type -func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceVar, targetVar string, svcCtx *codegen.AttributeContext, proto, usesrc bool, sd *ServiceData) *InitData { - pbCtx := protoBufTypeContext(sd.PkgName, sd.Scope, false) - name := "New" +func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceVar, targetVar string, svcCtx *codegen.AttributeContext, proto bool, sd *ServiceData, owner expr.ExampleIdentity, conversion *grpcConversion) *InitData { + protobufPackage := sd.ClientProtobufPkgName + if conversion.side == grpcServerPackage { + protobufPackage = sd.ServerProtobufPkgName + } + pbCtx := protoBufTypeContext(protobufPackage, sd, false) srcCtx := pbCtx tgtCtx := svcCtx if proto { srcCtx = svcCtx tgtCtx = pbCtx - name += "Proto" } isStruct := expr.IsObject(target.Type) || expr.IsUnion(target.Type) - if _, ok := source.Type.(expr.UserType); ok && usesrc { - name += protoBufGoTypeName(source, sd.Scope) - } - n := protoBufGoTypeName(target, sd.Scope) - if !isStruct { - // If target is array, map, or primitive the name will be suffixed with - // the definition (e.g int, []string, map[int]string) which is incorrect. - n = protoBufGoTypeName(source, sd.Scope) + if !conversion.bound { + if err := conversion.transform.BindContexts(srcCtx, tgtCtx); err != nil { + panic(err) // bug + } + conversion.bound = true } - name += n - code, helpers, err := protoBufTransform(source, target, sourceVar, targetVar, srcCtx, tgtCtx, proto, true) + code, helpers, err := conversion.transform.Render(sourceVar, targetVar, true) if err != nil { panic(err) // bug } - sd.transformHelpers = codegen.AppendHelpers(sd.transformHelpers, helpers) + if conversion.side == grpcServerPackage { + sd.serverTransformHelpers = codegen.AppendHelpers(sd.serverTransformHelpers, helpers) + } else { + sd.clientTransformHelpers = codegen.AppendHelpers(sd.clientTransformHelpers, helpers) + } var args []*InitArgData if (!proto && !isEmpty(source.Type)) || (proto && !isEmpty(target.Type)) { args = []*InitArgData{{ @@ -1120,13 +1590,20 @@ func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceV Ref: sourceVar, TypeName: srcCtx.Scope.Name(source, srcCtx.Pkg(source), srcCtx.Pointer, srcCtx.UseDefault), TypeRef: srcCtx.Scope.Ref(source, srcCtx.Pkg(source)), - Example: source.Example(d.Root.API.ExampleGenerator), + Example: d.Example(source, owner), }} } + sourceRef := "metadata values" + if !isEmpty(source.Type) { + sourceRef = srcCtx.Scope.Ref(source, srcCtx.Pkg(source)) + } + targetRef := tgtCtx.Scope.Ref(target, tgtCtx.Pkg(target)) return &InitData{ - Name: name, + Declaration: conversion.declaration, + Name: conversion.declaration.Name(), + Description: fmt.Sprintf("%s builds %s from %s.", conversion.declaration.Name(), targetRef, sourceRef), ReturnVarName: targetVar, - ReturnTypeRef: tgtCtx.Scope.Ref(target, tgtCtx.Pkg(target)), + ReturnTypeRef: targetRef, ReturnIsStruct: isStruct, ReturnTypePkg: tgtCtx.Pkg(target), Code: code, @@ -1134,6 +1611,42 @@ func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceV } } +// buildCLIRequestTransform renders the planned protobuf-to-payload conversion +// in the client package where command-line payload builders use it. +func (d *ServicesData) buildCLIRequestTransform(endpoint *expr.GRPCEndpointExpr, sd *ServiceData) string { + conversion := d.symbols[endpoint.Service].endpoints[endpoint].cliPayload + pbCtx := protoBufTypeContext(sd.ClientProtobufPkgName, sd, false) + svcCtx := d.serviceTypeContext(sd, "client").Enter(endpoint.MethodExpr.Payload) + if !conversion.bound { + if err := conversion.transform.BindContexts(pbCtx, svcCtx); err != nil { + panic(err) // bug + } + conversion.bound = true + } + code, helpers, err := conversion.transform.Render("message", "v", true) + if err != nil { + panic(err) // bug + } + sd.clientTransformHelpers = codegen.AppendHelpers(sd.clientTransformHelpers, helpers) + return code +} + +// initDeclaration returns the constructor and conversion requested for one +// endpoint value. Planning records both before generated package names are +// fixed. +func (d *ServicesData) initDeclaration(endpoint *expr.GRPCEndpointExpr, server bool, key grpcInitKey) *grpcConversion { + symbols := d.symbols[endpoint.Service].endpoints[endpoint] + declarations := symbols.clientInits + if server { + declarations = symbols.serverInits + } + init := declarations[key] + if init == nil { + panic(fmt.Sprintf("constructor name is missing for gRPC endpoint %q", endpoint.Name())) + } + return init +} + // buildErrorsData builds the error data for all the error responses in the // endpoint expression. The response message for each error response are // inferred from the method's error expression if not specified explicitly. @@ -1141,7 +1654,6 @@ func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceV // response message derived by analyze; errors without a custom object type // have no entry. func (d *ServicesData) buildErrorsData(e *expr.GRPCEndpointExpr, errorMessages map[string]*expr.AttributeExpr, sd *ServiceData) []*ErrorData { - svc := sd.Service errors := make([]*ErrorData, 0, len(e.GRPCErrors)) for _, v := range e.GRPCErrors { responseData := &ResponseData{ @@ -1150,10 +1662,10 @@ func (d *ServicesData) buildErrorsData(e *expr.GRPCEndpointExpr, errorMessages m ServerConvert: d.buildErrorConvertData(v, e, errorMessages[v.Name], sd, true), ClientConvert: d.buildErrorConvertData(v, e, errorMessages[v.Name], sd, false), } - errorLoc := svc.Method(e.MethodExpr.Name).ErrorLocs[v.Name] + svcctx := d.serviceTypeContext(sd, "server").Enter(v.AttributeExpr) errors = append(errors, &ErrorData{ Name: v.Name, - Ref: svc.Scope.GoFullTypeRef(v.AttributeExpr, errorLoc.PackageNameOrDefault(svc.PkgName)), + Ref: svcctx.Scope.Ref(v.AttributeExpr, svcctx.Pkg(v.AttributeExpr)), Response: responseData, }) } @@ -1166,37 +1678,103 @@ func (d *ServicesData) buildErrorsData(e *expr.GRPCEndpointExpr, errorMessages m func (d *ServicesData) buildErrorConvertData(ge *expr.GRPCErrorExpr, e *expr.GRPCEndpointExpr, message *expr.AttributeExpr, sd *ServiceData, svr bool) *ConvertData { // No need to build transformation functions for default error or non-object // types. - if ge.Type == expr.ErrorResult || !expr.IsObject(ge.Type) { + if expr.IsErrorResult(ge.Type) || !expr.IsObject(ge.Type) { return nil } - svc := sd.Service - svcCtx := serviceTypeContext(svc.PkgName, svc.Scope) + side := "client" + if svr { + side = "server" + } + svcCtx := d.serviceTypeContext(sd, side).Enter(ge.AttributeExpr) if svr { // server side - data := d.buildInitData(ge.AttributeExpr, message, "er", "message", svcCtx, true, false, sd) - data.Name = fmt.Sprintf("New%s%sError", codegen.Goify(e.Name(), true), codegen.Goify(ge.Name, true)) - data.Description = fmt.Sprintf("%s builds the gRPC error response type from the error of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) + owner := expr.MethodErrorExampleIdentity(e.MethodExpr, ge.ErrorExpr) + data := d.buildInitData(ge.AttributeExpr, message, "er", "message", svcCtx, true, sd, owner, d.initDeclaration(e, true, grpcInitKey{role: grpcErrorInit, subject: ge.Name})) return &ConvertData{ SrcName: svcCtx.Scope.Name(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr), svcCtx.Pointer, svcCtx.UseDefault), SrcRef: svcCtx.Scope.Ref(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr)), - TgtName: protoBufGoFullTypeName(message, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(message, sd.PkgName, sd.Scope), + TgtName: protoBufGoFullTypeName(message, sd.ServerProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(message, sd.ServerProtobufPkgName, sd), Init: data, } } // client side - data := d.buildInitData(message, ge.AttributeExpr, "message", "er", svcCtx, false, false, sd) - data.Name = fmt.Sprintf("New%s%sError", codegen.Goify(e.Name(), true), codegen.Goify(ge.Name, true)) - data.Description = fmt.Sprintf("%s builds the error type of the %q endpoint of the %q service from the gRPC error response type.", data.Name, e.Name(), svc.Name) + owner := expr.MethodErrorExampleIdentity(e.MethodExpr, ge.ErrorExpr) + data := d.buildInitData(message, ge.AttributeExpr, "message", "er", svcCtx, false, sd, owner, d.initDeclaration(e, false, grpcInitKey{role: grpcErrorInit, subject: ge.Name})) return &ConvertData{ - SrcName: protoBufGoFullTypeName(message, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(message, sd.PkgName, sd.Scope), + SrcName: protoBufGoFullTypeName(message, sd.ClientProtobufPkgName, sd), + SrcRef: protoBufGoFullTypeRef(message, sd.ClientProtobufPkgName, sd), TgtName: svcCtx.Scope.Name(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: svcCtx.Scope.Ref(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr)), Init: data, - Validation: addValidation(message, "errmsg", sd, false), + Validation: addValidation(message, sd, false), + } +} + +// buildServerStreamSendConverts builds one protobuf conversion for each result +// view the server may send through a stream. +func (d *ServicesData) buildServerStreamSendConverts(e *expr.GRPCEndpointExpr, response, result *expr.AttributeExpr, resultCtx *codegen.AttributeContext, sd *ServiceData) []*ViewConvertData { + views := []string{""} + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + views = grpcResultViews(e.MethodExpr) + } + converts := make([]*ViewConvertData, 0, len(views)) + for _, view := range views { + source := result + sourceVar := "result" + if view != "" { + var err error + source, err = grpcResultForView(result, view) + if err != nil { + panic(err) // bug + } + sourceVar = "vresult" + } + key := grpcInitKey{role: grpcStreamingResponseInit, view: view} + convert := &ConvertData{ + SrcName: resultCtx.Scope.Name(source, resultCtx.Pkg(source), resultCtx.Pointer, resultCtx.UseDefault), + SrcRef: resultCtx.Scope.Ref(source, resultCtx.Pkg(source)), + TgtName: protoBufGoFullTypeName(response, sd.ServerProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(response, sd.ServerProtobufPkgName, sd), + Init: d.buildInitData(source, response, sourceVar, "v", resultCtx, true, sd, expr.MethodStreamingResultExampleIdentity(e.MethodExpr), d.initDeclaration(e, true, key)), + } + converts = append(converts, &ViewConvertData{View: view, Convert: convert}) + } + return converts +} + +// buildClientStreamRecvConverts builds one service conversion for each result +// view the client may receive through a stream. +func (d *ServicesData) buildClientStreamRecvConverts(e *expr.GRPCEndpointExpr, response, result *expr.AttributeExpr, resultCtx *codegen.AttributeContext, sd *ServiceData) []*ViewConvertData { + views := []string{""} + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + views = grpcResultViews(e.MethodExpr) + } + converts := make([]*ViewConvertData, 0, len(views)) + for _, view := range views { + target := result + targetVar := "result" + if view != "" { + var err error + target, err = grpcResultForView(result, view) + if err != nil { + panic(err) // bug + } + targetVar = "vresult" + } + key := grpcInitKey{role: grpcStreamingResponseInit, view: view} + convert := &ConvertData{ + SrcName: protoBufGoFullTypeName(response, sd.ClientProtobufPkgName, sd), + SrcRef: protoBufGoFullTypeRef(response, sd.ClientProtobufPkgName, sd), + TgtName: resultCtx.Scope.Name(target, resultCtx.Pkg(target), resultCtx.Pointer, resultCtx.UseDefault), + TgtRef: resultCtx.Scope.Ref(target, resultCtx.Pkg(target)), + Init: d.buildInitData(response, target, "v", targetVar, resultCtx, false, sd, expr.MethodStreamingResultExampleIdentity(e.MethodExpr), d.initDeclaration(e, false, key)), + Validation: addValidation(response, sd, false), + } + converts = append(converts, &ViewConvertData{View: view, Convert: convert}) } + return converts } // buildStreamData builds the StreamData for the server and client streams. @@ -1216,39 +1794,39 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques sendWithContextDesc string sendRef string sendConvert *ConvertData + sendConverts []*ViewConvertData recvName string recvDesc string recvWithContextName string recvWithContextDesc string recvRef string recvConvert *ConvertData + recvConverts []*ViewConvertData mustClose bool typ string ) - svc := sd.Service ed := sd.Endpoint(e.Name()) md := ed.Method - svcCtx := serviceTypeContext(svc.PkgName, svc.Scope) - result, resCtx := resultContext(e, sd) - resVar := "result" - if md.ViewedResult != nil { - resVar = "vresult" + side := "client" + if svr { + side = "server" } + svcCtx := d.serviceTypeContext(sd, side).Enter(e.MethodExpr.StreamingPayload) + result, resCtx := d.resultContext(e, sd, side) if svr { typ = "server" varn = md.ServerStream.VarName - intName = fmt.Sprintf("%s.%s_%sServer", sd.PkgName, svc.StructName, md.VarName) - svcInt = fmt.Sprintf("%s.%s", svc.PkgName, md.ServerStream.Interface) + methodDescriptor := sd.protobuf.plan.serviceFullName() + "." + sd.protobuf.plan.methods[e] + intName = sd.ServerProtobufPkgName + "." + sd.protobuf.plan.name(methodDescriptor, protocMethodServerStreamName) + svcInt = fmt.Sprintf("%s.%s", sd.ServerServicePkgName, md.ServerStream.Interface) if e.MethodExpr.Result.Type != expr.Empty { sendName = md.ServerStream.SendName sendRef = ed.ResultRef sendWithContextName = md.ServerStream.SendWithContextName - sendConvert = &ConvertData{ - SrcName: resCtx.Scope.Name(result, resCtx.Pkg(result), resCtx.Pointer, resCtx.UseDefault), - SrcRef: resCtx.Scope.Ref(result, resCtx.Pkg(result)), - TgtName: protoBufGoFullTypeName(responseMessage, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(responseMessage, sd.PkgName, sd.Scope), - Init: d.buildInitData(result, responseMessage, resVar, "v", resCtx, true, true, sd), + sendConverts = d.buildServerStreamSendConverts(e, responseMessage, result, resCtx, sd) + sendConvert = primaryViewConvert(sendConverts) + if md.ViewedResult == nil { + sendConverts = nil } } if e.MethodExpr.StreamingPayload.Type != expr.Empty { @@ -1256,20 +1834,21 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques recvWithContextName = md.ServerStream.RecvWithContextName recvRef = svcCtx.Scope.Ref(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload)) recvConvert = &ConvertData{ - SrcName: protoBufGoFullTypeName(streamingRequest, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(streamingRequest, sd.PkgName, sd.Scope), + SrcName: protoBufGoFullTypeName(streamingRequest, sd.ServerProtobufPkgName, sd), + SrcRef: protoBufGoFullTypeRef(streamingRequest, sd.ServerProtobufPkgName, sd), TgtName: svcCtx.Scope.Name(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: recvRef, - Init: d.buildInitData(streamingRequest, e.MethodExpr.StreamingPayload, "v", "spayload", svcCtx, false, true, sd), - Validation: addValidation(streamingRequest, "stream", sd, true), + Init: d.buildInitData(streamingRequest, e.MethodExpr.StreamingPayload, "v", "spayload", svcCtx, false, sd, expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr), d.initDeclaration(e, true, grpcInitKey{role: grpcStreamingRequestInit})), + Validation: addValidation(streamingRequest, sd, true), } } mustClose = md.ServerStream.MustClose } else { typ = "client" varn = md.ClientStream.VarName - intName = fmt.Sprintf("%s.%s_%sClient", sd.PkgName, svc.StructName, md.VarName) - svcInt = fmt.Sprintf("%s.%s", svc.PkgName, md.ClientStream.Interface) + methodDescriptor := sd.protobuf.plan.serviceFullName() + "." + sd.protobuf.plan.methods[e] + intName = sd.ClientProtobufPkgName + "." + sd.protobuf.plan.name(methodDescriptor, protocMethodClientStreamName) + svcInt = fmt.Sprintf("%s.%s", sd.ClientServicePkgName, md.ClientStream.Interface) if e.MethodExpr.StreamingPayload.Type != expr.Empty { sendName = md.ClientStream.SendName sendWithContextName = md.ClientStream.SendWithContextName @@ -1277,22 +1856,19 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques sendConvert = &ConvertData{ SrcName: svcCtx.Scope.Name(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload), svcCtx.Pointer, svcCtx.UseDefault), SrcRef: sendRef, - TgtName: protoBufGoFullTypeName(streamingRequest, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(streamingRequest, sd.PkgName, sd.Scope), - Init: d.buildInitData(e.MethodExpr.StreamingPayload, streamingRequest, "spayload", "v", svcCtx, true, true, sd), + TgtName: protoBufGoFullTypeName(streamingRequest, sd.ClientProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(streamingRequest, sd.ClientProtobufPkgName, sd), + Init: d.buildInitData(e.MethodExpr.StreamingPayload, streamingRequest, "spayload", "v", svcCtx, true, sd, expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr), d.initDeclaration(e, false, grpcInitKey{role: grpcStreamingRequestInit})), } } if e.MethodExpr.Result.Type != expr.Empty { recvName = md.ClientStream.RecvName recvWithContextName = md.ClientStream.RecvWithContextName recvRef = ed.ResultRef - recvConvert = &ConvertData{ - SrcName: protoBufGoFullTypeName(responseMessage, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(responseMessage, sd.PkgName, sd.Scope), - TgtName: resCtx.Scope.Name(result, resCtx.Pkg(result), resCtx.Pointer, resCtx.UseDefault), - TgtRef: resCtx.Scope.Ref(result, resCtx.Pkg(result)), - Init: d.buildInitData(responseMessage, result, "v", resVar, resCtx, false, true, sd), - Validation: addValidation(responseMessage, "stream", sd, false), + recvConverts = d.buildClientStreamRecvConverts(e, responseMessage, result, resCtx, sd) + recvConvert = primaryViewConvert(recvConverts) + if md.ViewedResult == nil { + recvConverts = nil } } mustClose = md.ClientStream.MustClose @@ -1317,69 +1893,164 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques SendWithContextDesc: sendWithContextDesc, SendRef: sendRef, SendConvert: sendConvert, + SendConverts: sendConverts, RecvName: recvName, RecvDesc: recvDesc, RecvWithContextName: recvWithContextName, RecvWithContextDesc: recvWithContextDesc, RecvRef: recvRef, RecvConvert: recvConvert, + RecvConverts: recvConverts, MustClose: mustClose, } } +// primaryViewConvert returns the conversion kept in the original single-value +// data field. A caller-selected result uses its default view. A fixed result +// has only its selected view. +func primaryViewConvert(converts []*ViewConvertData) *ConvertData { + if len(converts) == 0 { + return nil + } + if len(converts) == 1 { + return converts[0].Convert + } + for _, convert := range converts { + if convert.View == expr.DefaultView { + return convert.Convert + } + } + panic("caller-selected gRPC result views do not include the default view") // bug +} + // extractMetadata collects the request/response metadata from the given // metadata attribute and service type (payload/result). -func extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, scope *codegen.NameScope, services ServicesData) []*MetadataData { - var metadata []*MetadataData - ctx := serviceTypeContext("", scope) - codegen.WalkMappedAttr(a, func(name, elem string, required bool, c *expr.AttributeExpr) error { // nolint: errcheck - arr := expr.AsArray(c.Type) - mp := expr.AsMap(c.Type) - typeRef := scope.GoTypeRef(unalias(c)) - ft := service.Type - varn := scope.Name(codegen.Goify(name, false)) - fieldName := codegen.Goify(name, true) - var pointer bool - if !expr.IsObject(service.Type) { - fieldName = "" - } else { - pointer = service.IsPrimitivePointer(name, true) - ft = service.Find(name).Type +func (d *ServicesData) extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, side, decodeTarget string, owner expr.ExampleIdentity) []*MetadataData { + plans, ok := d.metadataPlans[a] + if !ok { + panic("saved gRPC metadata fields are missing") + } + metadata := make([]*MetadataData, 0, len(plans)) + for _, plan := range plans { + wire := plan.wire + arr := expr.AsArray(wire.Type) + mp := expr.AsMap(wire.Type) + wireCtx := codegen.NewAttributeContext(false, false, true, "", plan.scope).Enter(wire) + var cliValidation func(string) string + if plan.validation != "" { + cliValidation = grpcCLIValidationRenderer(wire, wireCtx, plan.name) } - if pointer { + varn := codegen.Goify(plan.name, false) + fieldName := plan.fieldName + typeName := wireCtx.Scope.Name(wire, wireCtx.Pkg(wire), false, true) + typeRef := wireCtx.Scope.Ref(wire, wireCtx.Pkg(wire)) + valueTypeRef := typeRef + if plan.pointer { typeRef = "*" + typeRef } + serviceVar := "payload" + encodeSide := "client" + if side == "client" { + serviceVar = "result" + encodeSide = "server" + } + fieldRef := serviceVar + targetRef := decodeTarget + if fieldName != "" { + fieldRef += "." + fieldName + targetRef += "." + fieldName + } + wireVar := varn + "Wire" + encodeCode := d.metadataTransform(plan, fieldRef, wireVar, sd, encodeSide, true) + decodeCode := d.metadataTransform(plan, varn, targetRef, sd, side, false) metadata = append(metadata, &MetadataData{ - Name: elem, - AttributeName: name, - Description: c.Description, - FieldName: fieldName, - FieldType: ft, - VarName: varn, - Required: required, - Type: c.Type, - TypeName: scope.GoTypeName(unalias(c)), - TypeRef: typeRef, - Pointer: pointer, - Slice: arr != nil, - StringSlice: arr != nil && arr.ElemType.Type.Kind() == expr.StringKind, - Map: mp != nil, + Name: plan.element, + AttributeName: plan.name, + Description: wire.Description, + FieldName: fieldName, + FieldType: plan.fieldType, + ServiceAttribute: plan.serviceField, + WireAttribute: wire, + VarName: varn, + WireVarName: wireVar, + EncodeCode: encodeCode, + DecodeCode: decodeCode, + Required: plan.required, + Type: wire.Type, + TypeName: typeName, + TypeRef: typeRef, + Pointer: plan.pointer, + Slice: arr != nil, + StringSlice: arr != nil && arr.ElemType.Type.Kind() == expr.StringKind, + Map: mp != nil, MapStringSlice: mp != nil && mp.KeyType.Type.Kind() == expr.StringKind && mp.ElemType.Type.Kind() == expr.ArrayKind && expr.AsArray(mp.ElemType.Type).ElemType.Type.Kind() == expr.StringKind, - Validate: codegen.AttributeValidationCode(c, nil, ctx, required, false, varn, name), - DefaultValue: c.DefaultValue, - Example: c.Example(services.Root.API.ExampleGenerator.Field(service, name)), + Validate: plan.validation, + CLIPlan: cli.NewFlagPlan(wire, typeName, valueTypeRef, cliValidation), + DefaultValue: wire.DefaultValue, + Example: d.FieldExample(wire, service, plan.name, owner), }) - return nil - }) + } return metadata } -// initArgsFromMetadata converts the given metadata into constructor arguments -// so the metadata values can be passed to the generated init functions. -func initArgsFromMetadata(md []*MetadataData) []*InitArgData { +// metadataTransform writes the conversion between one generated metadata +// value and its service field using the imports of the generated file. +func (d *ServicesData) metadataTransform(plan *grpcMetadataPlan, sourceVar, targetVar string, sd *ServiceData, side string, encode bool) string { + wireCtx := codegen.NewAttributeContext(false, false, true, "", plan.scope).Enter(plan.wire) + serviceCtx := d.serviceTypeContext(sd, side).Enter(plan.serviceField) + sourceCtx, targetCtx := wireCtx, serviceCtx + transform := plan.decode + if encode { + sourceCtx, targetCtx = serviceCtx, wireCtx + transform = plan.encode + } + if err := transform.BindContexts(sourceCtx, targetCtx); err != nil { + panic(err) // bug + } + valueVar := sourceVar + if plan.pointer { + valueVar = "*" + sourceVar + } + if encode { + code, helpers, err := transform.Render(valueVar, targetVar, true) + if err != nil { + panic(err) + } + sd.appendMetadataHelpers(side, helpers) + return code + } + if !plan.pointer { + code, helpers, err := transform.Render(valueVar, targetVar, false) + if err != nil { + panic(err) + } + sd.appendMetadataHelpers(side, helpers) + return code + } + converted := codegen.Goify(sourceVar, false) + "Service" + code, helpers, err := transform.Render(valueVar, converted, true) + if err != nil { + panic(err) + } + sd.appendMetadataHelpers(side, helpers) + return "if " + sourceVar + " != nil {\n" + code + "\n" + targetVar + " = &" + converted + "\n}\n" +} + +// appendMetadataHelpers writes recursive metadata conversions on the same +// client or server side as the metadata codec that calls them. +func (sd *ServiceData) appendMetadataHelpers(side string, helpers []*codegen.TransformFunctionData) { + if side == "server" { + sd.serverTransformHelpers = codegen.AppendHelpers(sd.serverTransformHelpers, helpers) + } else { + sd.clientTransformHelpers = codegen.AppendHelpers(sd.clientTransformHelpers, helpers) + } +} + +// argsFromMetadata builds arguments that expose decoded metadata values. +func argsFromMetadata(md []*MetadataData) []*InitArgData { args := make([]*InitArgData, len(md)) for i, m := range md { args[i] = &InitArgData{ @@ -1393,6 +2064,7 @@ func initArgsFromMetadata(md []*MetadataData) []*InitArgData { Pointer: m.Pointer, Required: m.Required, Validate: m.Validate, + CLIPlan: m.CLIPlan, Example: m.Example, DefaultValue: m.DefaultValue, } @@ -1400,6 +2072,27 @@ func initArgsFromMetadata(md []*MetadataData) []*InitArgData { return args } +// grpcCLIValidationRenderer writes checks for the concrete value parsed from +// command-line metadata. Metadata fields use pointers to track presence, but +// the CLI has already proved presence and passes the parsed value itself. +func grpcCLIValidationRenderer(attribute *expr.AttributeExpr, context *codegen.AttributeContext, name string) func(string) string { + valueContext := context.Dup() + valueContext.Pointer = false + return func(target string) string { + return codegen.AttributeValidationCode(attribute, nil, valueContext, true, false, target, name) + } +} + +// initArgsFromMetadata adds the exact conversion that populates the service +// constructor result from each metadata argument. +func initArgsFromMetadata(md []*MetadataData) []*InitArgData { + args := argsFromMetadata(md) + for index, metadata := range md { + args[index].InitCode = metadata.DecodeCode + } + return args +} + // usesStreamEnvelope reports whether the transport needs a typed stream // envelope to carry both the one-shot method payload and streaming payload // items. @@ -1409,7 +2102,7 @@ func usesStreamEnvelope(e *expr.GRPCEndpointExpr) bool { // makeProtoBufStreamEnvelope builds the protobuf stream envelope that carries // the initial request payload frame and subsequent stream item frames. -func makeProtoBufStreamEnvelope(request, stream *expr.AttributeExpr, tname string, sd *ServiceData) *expr.AttributeExpr { +func makeProtoBufStreamEnvelope(request, stream *expr.AttributeExpr, tname string, owner expr.ExampleIdentity) *expr.AttributeExpr { initial := expr.DupAtt(request) initial.Meta = initial.Meta.Dup() initial.Meta["rpc:tag"] = []string{"1"} @@ -1433,61 +2126,129 @@ func makeProtoBufStreamEnvelope(request, stream *expr.AttributeExpr, tname strin }, Validation: &expr.ValidationExpr{Required: []string{"body"}}, } - return makeProtoBufMessage(envelope, tname, sd) + return makeProtoBufMessage(envelope, tname, owner) } // buildStreamEnvelopeData computes the generated Go names for the protobuf // oneof field and wrapper types of the synthesized stream envelope. -func buildStreamEnvelopeData(envelope *expr.AttributeExpr, message *service.UserTypeData, sd *ServiceData) *StreamEnvelopeData { +func buildStreamEnvelopeData(envelope *expr.AttributeExpr, sd *ServiceData) *StreamEnvelopeData { body := envelope.Find("body") union := expr.AsUnion(body.Type) - scope := &protoBufScope{scope: sd.Scope} + scope := &protoBufScope{service: sd, pkg: sd.ClientProtobufPkgName} + serverScope := &protoBufScope{service: sd, pkg: sd.ServerProtobufPkgName} fieldName := scope.Field(body, union.TypeName, true) initialFieldName := scope.Field(union.Values[0].Attribute, union.Values[0].Name, true) streamItemFieldName := scope.Field(union.Values[1].Attribute, union.Values[1].Name, true) return &StreamEnvelopeData{ - FieldName: fieldName, - InitialFieldName: initialFieldName, - InitialWrapperRef: sd.PkgName + "." + protocOneofWrapperRef(message.VarName, initialFieldName), - StreamItemFieldName: streamItemFieldName, - StreamItemWrapperRef: sd.PkgName + "." + protocOneofWrapperRef(message.VarName, streamItemFieldName), + FieldName: fieldName, + InitialFieldName: initialFieldName, + InitialWrapperRef: scope.OneofWrapper(union.Values[0].Attribute), + ClientInitialWrapperRef: scope.OneofWrapper(union.Values[0].Attribute), + ServerInitialWrapperRef: serverScope.OneofWrapper(union.Values[0].Attribute), + StreamItemFieldName: streamItemFieldName, + StreamItemWrapperRef: scope.OneofWrapper(union.Values[1].Attribute), + ClientStreamItemWrapperRef: scope.OneofWrapper(union.Values[1].Attribute), + ServerStreamItemWrapperRef: serverScope.OneofWrapper(union.Values[1].Attribute), } } -// unalias returns the underlying attribute of the given attribute when its -// type is a user type, recursing until a non user type is found. Unlike -// unAlias it also resolves user types with non-primitive bases (e.g. named -// arrays) which extractMetadata needs to compute the native metadata type -// references. -func unalias(att *expr.AttributeExpr) *expr.AttributeExpr { - if ut, ok := att.Type.(expr.UserType); ok { - if _, ok := ut.Attribute().Type.(expr.Primitive); ok { - return ut.Attribute() +// nativeMetadataAttribute copies a gRPC metadata value as a primitive or array +// of primitives. It removes named service types but keeps their default values +// and validation rules on the copy used by the transport. +func nativeMetadataAttribute(source *expr.AttributeExpr) *expr.AttributeExpr { + if userType, ok := source.Type.(expr.UserType); ok { + result := nativeMetadataAttribute(userType.Attribute()) + mergeNativeMetadataContract(result, source) + return result + } + result := &expr.AttributeExpr{ + Description: source.Description, + DefaultValue: source.DefaultValue, + UserExamples: source.UserExamples, + } + if source.Validation != nil { + result.Validation = source.Validation.Dup() + } + if source.Meta != nil { + result.Meta = source.Meta.Dup() + } + switch actual := source.Type.(type) { + case expr.Primitive: + result.Type = actual + case *expr.Array: + result.Type = &expr.Array{ + ElemType: nativeMetadataAttribute(actual.ElemType), + NonNullableElems: actual.NonNullableElems, } - return unalias(ut.Attribute()) + default: + panic(fmt.Sprintf("invalid gRPC metadata type %s", source.Type.Name())) } - return att + stripMetadataServiceNames(result) + return result } -// serviceTypeContext returns a contextual attribute for service types. Service -// types are Go types and uses non-pointers to hold attributes having default -// values. -func serviceTypeContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(false, false, true, pkg, scope) +// mergeNativeMetadataContract applies constraints authored on an alias use to +// the detached contract inherited from the alias declaration. +func mergeNativeMetadataContract(target, source *expr.AttributeExpr) { + if source.Description != "" { + target.Description = source.Description + } + if source.DefaultValue != nil { + target.DefaultValue = source.DefaultValue + } + if source.Validation != nil { + if target.Validation == nil { + target.Validation = source.Validation.Dup() + } else { + target.Validation.Merge(source.Validation) + } + } + if source.Meta != nil { + if target.Meta == nil { + target.Meta = make(expr.MetaExpr) + } + for name, values := range source.Meta { + target.Meta[name] = append([]string(nil), values...) + } + } + stripMetadataServiceNames(target) } -// resultContext returns the method result attribute and the result context for the given -// endpoint. -func resultContext(e *expr.GRPCEndpointExpr, sd *ServiceData) (*expr.AttributeExpr, *codegen.AttributeContext) { - svc := sd.Service - md := svc.Method(e.Name()) +// stripMetadataServiceNames removes Go service declaration overrides from a +// value rendered entirely in the generated gRPC client or server package. +func stripMetadataServiceNames(attribute *expr.AttributeExpr) { + for name := range attribute.Meta { + if strings.HasPrefix(name, "struct:") || name == "name:original" { + delete(attribute.Meta, name) + } + } +} + +// serviceTypeContext returns a context that resolves service declarations from +// the generated gRPC package for side. +func (d *ServicesData) serviceTypeContext(sd *ServiceData, side string) *codegen.AttributeContext { + outputPackage := path.Join(d.GenPkg(), "grpc", sd.Service.PathName, side) + return &codegen.AttributeContext{ + UseDefault: true, + Scope: d.ServiceAttributor(sd.Service.Name, outputPackage), + } +} + +// resultContext returns the method result and the final service or view type +// names used by the generated client or server package. +func (d *ServicesData) resultContext(e *expr.GRPCEndpointExpr, sd *ServiceData, side string) (*expr.AttributeExpr, *codegen.AttributeContext) { + md := sd.Service.Method(e.Name()) if md.ViewedResult != nil { vresAtt := expr.AsObject(md.ViewedResult.Type).Attribute("projected") - // return projected type context - return vresAtt, codegen.NewAttributeContext(true, false, true, svc.ViewsPkg, svc.ViewScope) + outputPackage := path.Join(d.GenPkg(), "grpc", sd.Service.PathName, side) + return vresAtt, &codegen.AttributeContext{ + Pointer: true, + UseDefault: true, + Scope: d.ViewAttributor(sd.Service.Name, outputPackage), + } } - pkg := md.ResultLoc.PackageNameOrDefault(svc.PkgName) - return e.MethodExpr.Result, serviceTypeContext(pkg, svc.Scope) + result := e.MethodExpr.Result + return result, d.serviceTypeContext(sd, side).Enter(result) } // getPrimitive returns the primitive expression if the given expression is an alias to one @@ -1541,8 +2302,14 @@ func usesAnyType(endpoints []*expr.GRPCEndpointExpr, includeErrors bool) bool { return false } -// hasAnyType recursively checks if the given attribute uses the Any type. +// hasAnyType reports whether the attribute uses Any without following a named +// type more than once. func hasAnyType(att *expr.AttributeExpr) bool { + return hasAnyTypeR(att, make(map[expr.UserType]struct{})) +} + +// hasAnyTypeR walks arrays, maps, objects, unions, and named types. +func hasAnyTypeR(att *expr.AttributeExpr, seen map[expr.UserType]struct{}) bool { if att == nil { return false } @@ -1551,20 +2318,25 @@ func hasAnyType(att *expr.AttributeExpr) bool { } switch dt := att.Type.(type) { case expr.UserType: - return hasAnyType(dt.Attribute()) + origin := dt.Origin() + if _, ok := seen[origin]; ok { + return false + } + seen[origin] = struct{}{} + return hasAnyTypeR(dt.Attribute(), seen) case *expr.Object: for _, nat := range *dt { - if hasAnyType(nat.Attribute) { + if hasAnyTypeR(nat.Attribute, seen) { return true } } case *expr.Array: - return hasAnyType(dt.ElemType) + return hasAnyTypeR(dt.ElemType, seen) case *expr.Map: - return hasAnyType(dt.KeyType) || hasAnyType(dt.ElemType) + return hasAnyTypeR(dt.KeyType, seen) || hasAnyTypeR(dt.ElemType, seen) case *expr.Union: for _, nat := range dt.Values { - if hasAnyType(nat.Attribute) { + if hasAnyTypeR(nat.Attribute, seen) { return true } } diff --git a/grpc/codegen/service_data_traversal_test.go b/grpc/codegen/service_data_traversal_test.go new file mode 100644 index 0000000000..acf6eb405d --- /dev/null +++ b/grpc/codegen/service_data_traversal_test.go @@ -0,0 +1,520 @@ +// This file verifies that gRPC validation discovery distinguishes unrelated +// declarations while stopping recursion through copied declarations. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +func TestCollectMessagesDistinguishesEqualNameAndUIDOrigins(t *testing.T) { + first := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + second := grpcMessageTraversalType("Shared", "shared", expr.Int, "2") + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + sd := grpcTraversalServiceData() + + messages := freezeTraversalMessages(t, sd, root) + require.Len(t, messages, 2) + require.NotEqual(t, messages[0].VarName, messages[1].VarName) + require.NotEqual(t, messages[0].Ref, messages[1].Ref) + require.Contains(t, messages[0].Def, "string value = 1") + require.Contains(t, messages[1].Def, "sint32 value = 2") +} + +func TestCollectMessagesDistinguishesOneOriginWithDifferentWireShape(t *testing.T) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := expr.Dup(original).(expr.UserType) + second := expr.Dup(original).(expr.UserType) + expr.AsObject(second).Attribute("value").Meta["rpc:tag"] = []string{"2"} + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + sd := grpcTraversalServiceData() + + messages := freezeTraversalMessages(t, sd, root) + require.Len(t, messages, 2) + require.NotEqual(t, messages[0].VarName, messages[1].VarName) + require.Contains(t, messages[0].Def, "string value = 1") + require.Contains(t, messages[1].Def, "string value = 2") +} + +func TestCollectMessagesReusesIdenticalDeclaration(t *testing.T) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := expr.Dup(original).(expr.UserType) + second := expr.Dup(original).(expr.UserType) + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), root) + require.Len(t, messages, 1) +} + +func TestCollectMessagesDistinguishesProtoOverrides(t *testing.T) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := &expr.AttributeExpr{ + Type: expr.Dup(original), + Meta: expr.MetaExpr{"struct:name:proto": {"FirstWire"}}, + } + second := &expr.AttributeExpr{ + Type: expr.Dup(original), + Meta: expr.MetaExpr{"struct:name:proto": {"SecondWire"}}, + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), root) + require.Len(t, messages, 2) + require.Equal(t, "FirstWire", messages[0].VarName) + require.Equal(t, "SecondWire", messages[1].VarName) +} + +func TestCollectMessagesDistinguishesSharedExplicitNameWithDifferentSchemas(t *testing.T) { + first := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("First", "first", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + second := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("Second", "second", expr.Int, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), root) + require.Len(t, messages, 2) + require.Equal(t, "SharedWire", messages[0].VarName) + require.Equal(t, "SharedWire2", messages[1].VarName) + require.Contains(t, messages[0].Def, "string value = 1") + require.Contains(t, messages[1].Def, "sint32 value = 1") +} + +func TestCollectMessagesDistinguishesSharedExplicitNameWithDifferentOrigins(t *testing.T) { + first := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("First", "first", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + second := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("Second", "second", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), root) + require.Len(t, messages, 2) + require.Equal(t, "SharedWire", messages[0].VarName) + require.Equal(t, "SharedWire2", messages[1].VarName) +} + +func TestCollectMessagesUsesUnaryResultSourceForMixedResults(t *testing.T) { + firstResult := grpcMessageTraversalType("FirstResult", "first-result", expr.String, "1") + secondResult := grpcMessageTraversalType("SecondResult", "second-result", expr.String, "1") + streamingResult := grpcMessageTraversalType("StreamingResult", "streaming-result", expr.String, "1") + firstEndpoint := &expr.GRPCEndpointExpr{MethodExpr: &expr.MethodExpr{ + Result: &expr.AttributeExpr{Type: firstResult}, + StreamingResult: &expr.AttributeExpr{Type: streamingResult}, + }} + secondEndpoint := &expr.GRPCEndpointExpr{MethodExpr: &expr.MethodExpr{ + Result: &expr.AttributeExpr{Type: secondResult}, + StreamingResult: &expr.AttributeExpr{Type: streamingResult}, + }} + firstWire := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("FirstWire", "first-wire", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + secondWire := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("SecondWire", "second-wire", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + sd := grpcTraversalServiceData() + sd.protobuf = newProtobufPackageCatalog(sd.PkgName) + require.NoError(t, sd.protobuf.collectMessage(firstWire, protobufRootMessageSource(firstWire, firstEndpoint, nil, protobufResponseMessage))) + require.NoError(t, sd.protobuf.collectMessage(secondWire, protobufRootMessageSource(secondWire, secondEndpoint, nil, protobufResponseMessage))) + planTestProtobufCatalog(t, sd) + + messages := sd.protobuf.freezeMessages(sd) + require.Len(t, messages, 2) + require.Equal(t, "SharedWire", messages[0].VarName) + require.Equal(t, "SharedWire2", messages[1].VarName) +} + +func TestCollectMessagesStopsAtRecursiveCopy(t *testing.T) { + message := grpcMessageTraversalType("Recursive", "recursive", expr.String, "1") + object := expr.AsObject(message) + *object = append(*object, &expr.NamedAttributeExpr{ + Name: "next", + Attribute: &expr.AttributeExpr{ + Type: message, + Meta: expr.MetaExpr{"rpc:tag": {"2"}}, + }, + }) + + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), &expr.AttributeExpr{Type: expr.Dup(message)}) + require.Len(t, messages, 1) + require.Contains(t, messages[0].Def, "Recursive next = 2") +} + +func TestProtoBufMessageNameRequiresFrozenDeclaration(t *testing.T) { + message := grpcMessageTraversalType("Unbound", "unbound", expr.String, "1") + sd := grpcTraversalServiceData() + + require.Panics(t, func() { + protoBufMessageName(&expr.AttributeExpr{Type: message}, sd) + }) +} + +func TestProtoBufMessageNameIgnoresLateScopeAllocations(t *testing.T) { + message := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + attribute := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + messages := freezeTraversalMessages(t, sd, attribute) + require.Len(t, messages, 1) + + sd.Scope.HashedUnique(grpcMessageTraversalType("Other", "other", expr.Int, "1"), "Shared") + require.Equal(t, messages[0].VarName, protoBufMessageName(attribute, sd)) +} + +// TestProtobufCopiesRequireRegistration checks that a copied protobuf value +// uses names only after the copy is connected to the original value. +func TestProtobufCopiesRequireRegistration(t *testing.T) { + minimum := 2 + state := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "State", + Values: []*expr.NamedAttributeExpr{ + { + Name: "active", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"rpc:tag": {"1"}}, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + }, + }, + }, + }} + message := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "state", Attribute: state}, + }}, + TypeName: "Message", + UID: "message", + } + attribute := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + freezeTraversalMessages(t, sd, attribute) + sd.protobuf.collectValidation(attribute, validateServer, grpcTraversalValidationSource(), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + copy := expr.DupAtt(attribute) + copyState := expr.AsObject(copy.Type.(expr.UserType)).Attribute("state") + branch := state.Type.(*expr.Union).Values[0].Attribute + copyBranch := copyState.Type.(*expr.Union).Values[0].Attribute + require.Nil(t, sd.protobuf.messageRecord(copy)) + require.Panics(t, func() { + sd.protobuf.unionName(copyState) + }) + _, ok := sd.protobuf.plan.wrapperName(copyBranch) + require.False(t, ok) + + sd.protobuf.plan.bindAttributeCopy(attribute, copy) + require.Same(t, sd.protobuf.messageRecord(attribute), sd.protobuf.messageRecord(copy)) + require.Equal(t, sd.protobuf.unionName(state), sd.protobuf.unionName(copyState)) + require.Nil(t, sd.protobuf.validationRecord(copy, validateServer)) + originalWrapper, ok := sd.protobuf.plan.wrapperName(branch) + require.True(t, ok) + copyWrapper, ok := sd.protobuf.plan.wrapperName(copyBranch) + require.True(t, ok) + require.Equal(t, originalWrapper, copyWrapper) +} + +// TestProtobufValidationScopeKeepsMessageNameSeparate checks that a validation +// function collision cannot change the protobuf message name used in its body. +func TestProtobufValidationScopeKeepsMessageNameSeparate(t *testing.T) { + minimum := 2 + message := grpcValidationTraversalType("Message", "message", &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + }) + attribute := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + freezeTraversalMessages(t, sd, attribute) + sd.protobuf.collectValidation(attribute, validateServer, grpcTraversalValidationSource(), "message", "message") + planTraversalValidations(t, sd) + record := sd.protobuf.validationRecord(attribute, validateServer) + require.NotNil(t, record) + record.message.name = "RetainedMessage2" + scope := &protobufValidationScope{ + protoBufScope: &protoBufScope{service: sd}, + catalog: sd.protobuf, + side: validateServer, + message: record.message, + parent: message, + } + + require.Equal(t, record.message.name, scope.Name(attribute, "", false, false)) +} + +func TestAddValidationDistinguishesRulesForOneWireDeclaration(t *testing.T) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := expr.Dup(original).(expr.UserType) + second := expr.Dup(original).(expr.UserType) + firstMinimum := 2 + secondMinimum := 5 + expr.AsObject(first).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &firstMinimum} + expr.AsObject(second).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &secondMinimum} + sd := grpcTraversalServiceData() + firstAttribute := &expr.AttributeExpr{Type: first} + secondAttribute := &expr.AttributeExpr{Type: second} + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: firstAttribute}, + {Name: "second", Attribute: secondAttribute}, + }} + freezeTraversalMessages(t, sd, root) + source := grpcTraversalValidationSource() + sd.protobuf.collectValidation(firstAttribute, validateServer, source, "message", "message") + sd.protobuf.collectValidation(secondAttribute, validateServer, source.child("second"), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + firstValidation := addValidation(firstAttribute, sd, true) + secondValidation := addValidation(secondAttribute, sd, true) + require.NotNil(t, firstValidation) + require.NotNil(t, secondValidation) + require.Len(t, sd.validations, 2) + require.NotEqual(t, firstValidation.Declaration.Name(), secondValidation.Declaration.Name()) + require.Contains(t, firstValidation.Def, `InvalidLengthError("message.value", *message.Value, utf8.RuneCountInString(*message.Value), 2, true)`) + require.Contains(t, secondValidation.Def, `InvalidLengthError("message.value", *message.Value, utf8.RuneCountInString(*message.Value), 5, true)`) +} + +func TestAddValidationDistinguishesGeneratedSide(t *testing.T) { + minimum := 2 + message := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + expr.AsObject(message).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &minimum} + attribute := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + freezeTraversalMessages(t, sd, attribute) + source := grpcTraversalValidationSource() + sd.protobuf.collectValidation(attribute, validateServer, source, "message", "message") + response := source + response.role = protobufResponseValidation + sd.protobuf.collectValidation(attribute, validateClient, response, "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + server := addValidation(attribute, sd, true) + client := addValidation(attribute, sd, false) + require.NotNil(t, server) + require.NotNil(t, client) + require.Len(t, sd.validations, 2) + require.Equal(t, validateServer, server.Kind) + require.Equal(t, validateClient, client.Kind) +} + +func TestAddValidationReusesIdenticalRulesOnOneSide(t *testing.T) { + minimum := 2 + message := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + expr.AsObject(message).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &minimum} + first := &expr.AttributeExpr{Type: expr.Dup(message)} + second := &expr.AttributeExpr{Type: expr.Dup(message)} + sd := grpcTraversalServiceData() + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + freezeTraversalMessages(t, sd, root) + source := grpcTraversalValidationSource() + sd.protobuf.collectValidation(first, validateServer, source, "message", "message") + sd.protobuf.collectValidation(second, validateServer, source.child("second"), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + require.Len(t, sd.validations, 1) + require.Same(t, addValidation(first, sd, true), addValidation(second, sd, true)) +} + +func TestCollectValidationsDistinguishesEqualUIDOrigins(t *testing.T) { + minimumLength := 3 + minimum := 5.0 + first := grpcValidationTraversalType("First", "shared", &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimumLength}, + }) + second := grpcValidationTraversalType("Second", "shared", &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }) + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + sd := &ServiceData{PkgName: "pb", Scope: codegen.NewNameScope()} + + freezeTraversalMessages(t, sd, root) + sd.protobuf.collectValidation(root, validateServer, grpcTraversalValidationSource(), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + names := make([]string, 0, len(sd.validations)) + for _, validation := range sd.validations { + names = append(names, validation.SrcName) + } + require.ElementsMatch(t, []string{"First", "Second"}, names) +} + +// grpcTraversalValidationSource describes the request used by focused +// validation tests. +func grpcTraversalValidationSource() protobufValidationSource { + return protobufValidationSource{ + api: "TestAPI", + service: "TestService", + method: "Call", + role: protobufRequestValidation, + } +} + +// planTraversalValidations chooses the function names used by these focused +// message and validation tests before building the function bodies. +func planTraversalValidations(t *testing.T, sd *ServiceData) { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + client, err := generation.ClaimPackage("generated.local/gen/grpc/test/client") + require.NoError(t, err) + server, err := generation.ClaimPackage("generated.local/gen/grpc/test/server") + require.NoError(t, err) + for _, record := range sd.protobuf.validators { + pkg := client + side := grpcClientPackage + if record.side == validateServer { + pkg = server + side = grpcServerPackage + } + id := grpcSymbolID{ + side: side, + role: grpcValidationRole, + api: record.source.api, + service: record.source.service, + method: record.source.method, + subject: record.source.error, + path: record.source.path, + operation: int(record.source.role), + } + record.declaration = codegen.NewPreferredName( + codegen.NameFunction, + "Validate"+record.message.plannedName, + codegen.ExportedName, + grpcSymbolOrder(id), + ) + require.NoError(t, pkg.DeclareName(record.declaration)) + } + require.NoError(t, generation.Freeze()) +} + +// grpcValidationTraversalType builds an authored message declaration with one +// constrained field so validation discovery must emit a helper for it. +func grpcValidationTraversalType(name, uid string, field *expr.AttributeExpr) *expr.UserTypeExpr { + if field.Meta == nil { + field.Meta = make(expr.MetaExpr) + } + field.Meta["rpc:tag"] = []string{"1"} + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: field}, + }}, + TypeName: name, + UID: uid, + } +} + +// grpcMessageTraversalType builds a protobuf message declaration with one +// explicitly numbered field. +func grpcMessageTraversalType(name, uid string, fieldType expr.DataType, tag string) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{ + Type: fieldType, + Meta: expr.MetaExpr{"rpc:tag": {tag}}, + }}, + }}, + TypeName: name, + UID: uid, + } +} + +// grpcTraversalServiceData supplies the protobuf package and field scope used +// by focused declaration and validator catalog tests. +func grpcTraversalServiceData() *ServiceData { + return &ServiceData{ + Name: "Service", + PkgName: "servicepb", + Scope: codegen.NewNameScope(), + Service: &service.Data{}, + } +} + +// freezeTraversalMessages collects and freezes every message reachable from +// root in the focused test protobuf package. +func freezeTraversalMessages(t *testing.T, sd *ServiceData, root *expr.AttributeExpr) []*service.UserTypeData { + sd.protobuf = newProtobufPackageCatalog(sd.PkgName) + require.NoError(t, sd.protobuf.collectMessage(root, protobufMessageSource{})) + planTestProtobufCatalog(t, sd) + sd.Messages = sd.protobuf.freezeMessages(sd) + return sd.Messages +} + +// planTestProtobufCatalog chooses names for the messages and validation +// functions created directly by these focused tests. +func planTestProtobufCatalog(t *testing.T, sd *ServiceData) { + t.Helper() + require.NotEmpty(t, sd.protobuf.messages) + message := sd.protobuf.messages[0].uses[0] + serviceExpr := &expr.ServiceExpr{Name: "GoaCatalogTestService"} + grpcService := &expr.GRPCServiceExpr{ServiceExpr: serviceExpr} + method := &expr.MethodExpr{ + Name: "Call", + Service: serviceExpr, + Payload: &expr.AttributeExpr{Type: expr.Empty}, + StreamingPayload: &expr.AttributeExpr{Type: expr.Empty}, + Result: &expr.AttributeExpr{Type: expr.Empty}, + StreamingResult: &expr.AttributeExpr{Type: expr.Empty}, + } + endpoint := &expr.GRPCEndpointExpr{MethodExpr: method, Service: grpcService} + grpcService.GRPCEndpoints = []*expr.GRPCEndpointExpr{endpoint} + plan := &protobufServicePlan{ + expression: grpcService, + catalog: sd.protobuf, + messages: []*protobufEndpointMessages{{request: message, response: message}}, + protoPackage: "goa_catalog_test", + methods: map[*expr.GRPCEndpointExpr]string{}, + names: make(map[protocNameKey]*codegen.NameDeclaration), + localNames: make(map[protocNameKey]string), + fields: make(map[*expr.AttributeExpr]protocNameKey), + sourceFields: make(map[*expr.AttributeExpr]string), + sourceOneofs: make(map[*expr.AttributeExpr]string), + wrappers: make(map[*expr.AttributeExpr]protocNameKey), + oneofs: make(map[*expr.AttributeExpr]protocNameKey), + } + sd.protobuf.plan = plan + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("generated.local/gen/grpc/test/pb") + require.NoError(t, err) + require.NoError(t, plan.chooseNames(pkg, make(map[string]struct{}))) + require.NoError(t, generation.Freeze()) +} diff --git a/grpc/codegen/service_imports.go b/grpc/codegen/service_imports.go new file mode 100644 index 0000000000..8f7c06a4a2 --- /dev/null +++ b/grpc/codegen/service_imports.go @@ -0,0 +1,41 @@ +// This file derives imports from the gRPC endpoint sections rendered into one +// generated file. +package codegen + +import ( + "path" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// addEndpointImports adds the packages recorded for one service to a generated +// file and omits the package that contains the file itself. +func addEndpointImports(file *codegen.File, services *ServicesData, service *grpcServicePlan) *codegen.File { + outputPath := strings.TrimPrefix(strings.ReplaceAll(file.Path, "\\", "/"), codegen.Gendir+"/") + outputPackage := path.Join(services.GenPkg(), path.Dir(outputPath)) + owner := services.generation.Package(outputPackage) + imports := make([]*codegen.ImportSpec, 0, len(service.imports)) + for _, importPath := range service.imports { + if importPath != outputPackage { + imports = append(imports, owner.Import(importPath)) + } + } + codegen.AddImport(file.SectionTemplates[0], imports...) + return file +} + +// grpcEndpointAttributes returns the named service attributes referenced by +// the supplied gRPC endpoint sections. +func grpcEndpointAttributes(endpoints ...*expr.GRPCEndpointExpr) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, endpoint := range endpoints { + method := endpoint.MethodExpr + attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result, method.StreamingResult) + for _, methodError := range method.Errors { + attributes = append(attributes, methodError.AttributeExpr) + } + } + return attributes +} diff --git a/grpc/codegen/service_metadata_reference_test.go b/grpc/codegen/service_metadata_reference_test.go new file mode 100644 index 0000000000..4413af8566 --- /dev/null +++ b/grpc/codegen/service_metadata_reference_test.go @@ -0,0 +1,79 @@ +// This file verifies that gRPC metadata uses detached native wire values and +// canonical conversions to frozen service declarations. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestMetadataConversionUsesDetachedWireAndFrozenServiceDeclaration(t *testing.T) { + root := expr.RunDSL(t, func() { + value := dsl.Type("Value", dsl.String, func() { + dsl.Meta("struct:pkg:path", "domain/shared") + }) + payload := dsl.Type("Payload", func() { + dsl.Field(1, "value", value) + dsl.Required("value") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + dsl.GRPC(func() { + dsl.Metadata(func() { dsl.Attribute("value") }) + }) + }) + }) + }) + + metadata := CreateGRPCServices(root).Get("Values").Endpoint("Read").Request.Metadata + require.Len(t, metadata, 1) + require.False(t, metadata[0].Map) + require.False(t, metadata[0].MapStringSlice) + require.Equal(t, "string", metadata[0].TypeRef) + require.NotContains(t, metadata[0].WireAttribute.Meta, "struct:pkg:path") + require.Contains(t, metadata[0].EncodeCode, "string(payload.Value)") + require.Contains(t, initArgsFromMetadata(metadata)[0].InitCode, "shared.Value(value)") +} + +func TestMetadataConversionRecursivelyDetachesNamedArrayElements(t *testing.T) { + root := expr.RunDSL(t, func() { + value := dsl.Type("Value", dsl.Int, func() { + dsl.Enum(1, 2) + dsl.Meta("struct:pkg:path", "domain/shared") + }) + values := dsl.Type("Values", dsl.ArrayOf(value), func() { + dsl.Meta("struct:pkg:path", "domain/shared") + }) + payload := dsl.Type("Payload", func() { + dsl.Field(1, "values", values) + dsl.Required("values") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + dsl.GRPC(func() { + dsl.Metadata(func() { dsl.Attribute("values") }) + }) + }) + }) + }) + + serviceField := root.API.GRPC.Services[0].GRPCEndpoints[0].MethodExpr.Payload.Find("values") + metadata := CreateGRPCServices(root).Get("Values").Endpoint("Read").Request.Metadata + require.Len(t, metadata, 1) + wireArray := expr.AsArray(metadata[0].WireAttribute.Type) + require.NotNil(t, wireArray) + require.Equal(t, expr.Int, wireArray.ElemType.Type) + require.Equal(t, []any{1, 2}, wireArray.ElemType.Validation.Values) + require.NotContains(t, metadata[0].WireAttribute.Meta, "struct:pkg:path") + require.NotContains(t, wireArray.ElemType.Meta, "struct:pkg:path") + require.Contains(t, metadata[0].EncodeCode, "int(val)") + require.Contains(t, initArgsFromMetadata(metadata)[0].InitCode, "shared.Value(val)") + require.Contains(t, serviceField.Type.(expr.UserType).Attribute().Meta, "struct:pkg:path") + require.Contains(t, expr.AsArray(serviceField.Type).ElemType.Type.(expr.UserType).Attribute().Meta, "struct:pkg:path") +} diff --git a/grpc/codegen/service_plan.go b/grpc/codegen/service_plan.go new file mode 100644 index 0000000000..f11edf855b --- /dev/null +++ b/grpc/codegen/service_plan.go @@ -0,0 +1,594 @@ +// This file copies each gRPC service and endpoint while NewPlans can still +// read the evaluated design. Link and the file builders read these copies. +package codegen + +import ( + "fmt" + "path" + "sort" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // grpcServicePlan stores one copied gRPC service and the values selected for + // its generated files. + grpcServicePlan struct { + source *expr.GRPCServiceExpr + expression *expr.GRPCServiceExpr + packages *grpcServicePackage + endpoints []*grpcEndpointPlan + endpointByExpr map[*expr.GRPCEndpointExpr]*grpcEndpointPlan + imports []string + protoImports []string + protoGoImports []*codegen.ImportSpec + scope *codegen.NameScope + usesAny bool + usesAnyInErrors bool + } + + // grpcEndpointPlan stores one copied endpoint and the metadata conversions + // that must be prepared before generated names are available. + grpcEndpointPlan struct { + expression *expr.GRPCEndpointExpr + legacyStream bool + legacyMetadata *expr.MappedAttributeExpr + metadata map[*expr.MappedAttributeExpr][]*grpcMetadataPlan + } + + // grpcMetadataPlan stores one metadata field, its Go field, and the code that + // converts the value in both directions. + grpcMetadataPlan struct { + name string + element string + required bool + fieldName string + fieldType expr.DataType + pointer bool + serviceField *expr.AttributeExpr + wire *expr.AttributeExpr + scope *codegen.NameScope + validation string + encode *codegen.TransformPlan + decode *codegen.TransformPlan + } +) + +// collectGRPCServicePlans copies the services selected for one gRPC plan and +// records the imports and metadata conversions used by their generated files. +func collectGRPCServicePlans(generation *codegen.Generation, plan *Plan) ([]*grpcServicePlan, error) { + services := make([]*grpcServicePlan, len(plan.expressions)) + for index, source := range plan.expressions { + service, err := copyGRPCService(source) + if err != nil { + return nil, err + } + service.scope = codegen.NewNameScope() + service.usesAny = usesAnyType(service.expression.GRPCEndpoints, false) + service.usesAnyInErrors = usesAnyType(service.expression.GRPCEndpoints, true) + service.imports = grpcServiceImportPaths(generation, service.expression) + service.packages = plan.packages[source] + + plannedProtobuf := plan.protobuf[source] + plannedTools := plan.tools[source] + plannedSymbols := plan.symbols[source] + service.protoImports, service.protoGoImports = collectGRPCProtobufImports(plannedProtobuf) + plan.protobuf[service.expression] = plannedProtobuf + plan.tools[service.expression] = plannedTools + plan.symbols[service.expression] = plannedSymbols + for endpointIndex, endpoint := range service.endpoints { + sourceEndpoint := source.GRPCEndpoints[endpointIndex] + if plannedSymbols != nil { + plannedSymbols.endpoints[endpoint.expression] = plannedSymbols.endpoints[sourceEndpoint] + } + if plannedProtobuf != nil { + plannedProtobuf.methods[endpoint.expression] = plannedProtobuf.methods[sourceEndpoint] + } + if declaration := plan.cli.builders[sourceEndpoint]; declaration != nil { + plan.cli.builders[endpoint.expression] = declaration + } + if err := planEndpointMetadata(endpoint); err != nil { + return nil, fmt.Errorf("plan gRPC metadata for %q.%q: %w", service.expression.Name(), endpoint.expression.Name(), err) + } + } + if err := replaceGRPCTransforms(plan.service, source, service, plannedProtobuf, plannedSymbols); err != nil { + return nil, fmt.Errorf("copy gRPC conversions for service %q: %w", service.expression.Name(), err) + } + services[index] = service + } + return services, nil +} + +// collectGRPCProtobufImports records the protobuf schema files and Go packages +// selected by every saved protobuf message. +func collectGRPCProtobufImports(protobuf *protobufServicePlan) ([]string, []*codegen.ImportSpec) { + if protobuf == nil { + return nil, nil + } + var attributes []*expr.AttributeExpr + for _, messages := range protobuf.messages { + attributes = append(attributes, messages.request, messages.streamingRequest, messages.requestEnvelope, messages.response) + errorNames := make([]string, 0, len(messages.errors)) + for name := range messages.errors { + errorNames = append(errorNames, name) + } + sort.Strings(errorNames) + for _, name := range errorNames { + attributes = append(attributes, messages.errors[name]) + } + } + var protoImports []string + var goImports []*codegen.ImportSpec + seenProto := make(map[string]struct{}) + seenGo := make(map[string]struct{}) + seenTypes := make(map[expr.UserType]struct{}) + var walk func(*expr.AttributeExpr) + walk = func(attribute *expr.AttributeExpr) { + if attribute == nil { + return + } + if field := attribute.Meta["struct:field:proto"]; len(field) > 1 { + if _, ok := seenProto[field[1]]; !ok { + seenProto[field[1]] = struct{}{} + protoImports = append(protoImports, field[1]) + } + if len(field) > 3 { + if _, ok := seenGo[field[3]]; !ok { + seenGo[field[3]] = struct{}{} + goImports = append(goImports, codegen.NewImport(path.Base(field[3]), field[3])) + } + } + } + if attribute.Type.Kind() == expr.AnyKind { + const structProto = "google/protobuf/struct.proto" + if _, ok := seenProto[structProto]; !ok { + seenProto[structProto] = struct{}{} + protoImports = append(protoImports, structProto) + } + return + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if _, ok := seenTypes[actual]; ok { + return + } + seenTypes[actual] = struct{}{} + walk(actual.Attribute()) + case *expr.Object: + for _, named := range *actual { + walk(named.Attribute) + } + case *expr.Array: + walk(actual.ElemType) + case *expr.Map: + walk(actual.KeyType) + walk(actual.ElemType) + case *expr.Union: + for _, named := range actual.Values { + walk(named.Attribute) + } + } + } + for _, attribute := range attributes { + walk(attribute) + } + return protoImports, goImports +} + +// replaceGRPCTransforms rebuilds every conversion from the copied method. This +// prevents later changes to the original design from changing generated code. +func replaceGRPCTransforms( + servicePlan *service.Plan, + source *expr.GRPCServiceExpr, + grpcService *grpcServicePlan, + protobuf *protobufServicePlan, + symbols *grpcSymbols, +) error { + if protobuf == nil || symbols == nil { + return fmt.Errorf("protobuf messages or Go names are missing") + } + replaced := make(map[*grpcConversion]struct{}) + replace := func(conversion *grpcConversion, source, target *expr.AttributeExpr, proto bool) error { + if conversion == nil { + return nil + } + if _, ok := replaced[conversion]; ok { + return nil + } + transform, err := newGRPCTransformPlan(source, target, proto, protobuf) + if err != nil { + return err + } + oldHelpers := conversion.transform.Helpers() + newHelpers := transform.Helpers() + if len(oldHelpers) != len(newHelpers) { + return fmt.Errorf("saved conversion helper count changed from %d to %d", len(oldHelpers), len(newHelpers)) + } + for index, helper := range newHelpers { + if err := transform.BindHelperDeclaration(helper.ID, oldHelpers[index].Declaration); err != nil { + return err + } + } + conversion.transform = transform + conversion.bound = false + replaced[conversion] = struct{}{} + return nil + } + for index, endpointPlan := range grpcService.endpoints { + endpoint := endpointPlan.expression + sourceEndpoint := source.GRPCEndpoints[index] + messages := protobuf.messages[index] + endpointSymbols := symbols.endpoints[endpoint] + result := endpoint.MethodExpr.Result + if _, viewed := sourceEndpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + projected, err := servicePlan.ProjectedResult(sourceEndpoint.MethodExpr) + if err != nil { + return err + } + result = expr.DupAtt(projected) + } + if endpoint.MethodExpr.Payload.Type != expr.Empty { + if err := replace(endpointSymbols.serverInits[grpcInitKey{role: grpcRequestInit}], messages.request, endpoint.MethodExpr.Payload, false); err != nil { + return err + } + if err := replace(endpointSymbols.cliPayload, messages.request, endpoint.MethodExpr.Payload, false); err != nil { + return err + } + } + if !(endpoint.MethodExpr.IsPayloadStreaming() && isEmpty(endpoint.Request.Type)) { + if err := replace(endpointSymbols.clientInits[grpcInitKey{role: grpcRequestInit}], endpoint.MethodExpr.Payload, messages.request, true); err != nil { + return err + } + } + if err := replace(endpointSymbols.serverInits[grpcInitKey{role: grpcResponseInit}], result, messages.response, true); err != nil { + return err + } + if endpoint.MethodExpr.Result.Type != expr.Empty && !endpoint.MethodExpr.IsStreaming() { + if err := replace(endpointSymbols.clientInits[grpcInitKey{role: grpcResponseInit}], messages.response, result, false); err != nil { + return err + } + } + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + key := grpcInitKey{role: grpcStreamingRequestInit} + if err := replace(endpointSymbols.serverInits[key], messages.streamingRequest, endpoint.MethodExpr.StreamingPayload, false); err != nil { + return err + } + if err := replace(endpointSymbols.clientInits[key], endpoint.MethodExpr.StreamingPayload, messages.streamingRequest, true); err != nil { + return err + } + } + if endpoint.MethodExpr.Result.Type != expr.Empty && endpoint.MethodExpr.IsStreaming() { + key := grpcInitKey{role: grpcStreamingResponseInit} + if err := replace(endpointSymbols.serverInits[key], result, messages.response, true); err != nil { + return err + } + if err := replace(endpointSymbols.clientInits[key], messages.response, result, false); err != nil { + return err + } + } + if endpointPlan.legacyStream && expr.IsObject(endpoint.MethodExpr.Payload.Type) { + key := grpcInitKey{role: grpcLegacyRequestInit} + if err := replace(endpointSymbols.serverInits[key], &expr.AttributeExpr{Type: expr.Empty}, endpoint.MethodExpr.Payload, false); err != nil { + return err + } + } + for _, grpcError := range endpoint.GRPCErrors { + message := messages.errors[grpcError.Name] + if message == nil { + continue + } + key := grpcInitKey{role: grpcErrorInit, subject: grpcError.Name} + if err := replace(endpointSymbols.serverInits[key], grpcError.AttributeExpr, message, true); err != nil { + return err + } + if err := replace(endpointSymbols.clientInits[key], message, grpcError.AttributeExpr, false); err != nil { + return err + } + } + } + return nil +} + +// copyGRPCService makes a private copy of the service values read by gRPC +// planning and rendering. +func copyGRPCService(source *expr.GRPCServiceExpr) (*grpcServicePlan, error) { + serviceExpr := &expr.ServiceExpr{ + Name: source.ServiceExpr.Name, + Description: source.ServiceExpr.Description, + Meta: copyGRPCMeta(source.ServiceExpr.Meta), + } + serviceExpr.Errors = make([]*expr.ErrorExpr, len(source.ServiceExpr.Errors)) + for index, sourceError := range source.ServiceExpr.Errors { + serviceExpr.Errors[index] = &expr.ErrorExpr{ + Name: sourceError.Name, + AttributeExpr: copyGRPCErrorAttribute(sourceError.AttributeExpr), + } + } + service := &expr.GRPCServiceExpr{ + ServiceExpr: serviceExpr, + ParentName: source.ParentName, + ProtoPkg: source.ProtoPkg, + Meta: copyGRPCMeta(source.Meta), + } + result := &grpcServicePlan{ + source: source, + expression: service, + endpoints: make([]*grpcEndpointPlan, len(source.GRPCEndpoints)), + endpointByExpr: make(map[*expr.GRPCEndpointExpr]*grpcEndpointPlan, len(source.GRPCEndpoints)), + } + for index, sourceEndpoint := range source.GRPCEndpoints { + method := copyGRPCMethod(sourceEndpoint.MethodExpr, serviceExpr) + serviceExpr.Methods = append(serviceExpr.Methods, method) + endpoint := &expr.GRPCEndpointExpr{ + MethodExpr: method, + Service: service, + Request: expr.DupAtt(sourceEndpoint.Request), + StreamingRequest: expr.DupAtt(sourceEndpoint.StreamingRequest), + Response: copyGRPCResponse(sourceEndpoint.Response), + Metadata: expr.DupMappedAtt(sourceEndpoint.Metadata), + Requirements: copyGRPCRequirements(sourceEndpoint.Requirements), + Meta: copyGRPCMeta(sourceEndpoint.Meta), + } + endpoint.Response.Parent = endpoint + endpoint.GRPCErrors = make([]*expr.GRPCErrorExpr, len(sourceEndpoint.GRPCErrors)) + for errorIndex, sourceError := range sourceEndpoint.GRPCErrors { + methodError := method.Error(sourceError.Name) + if methodError == nil { + return nil, fmt.Errorf("gRPC error %q is not defined by method %q", sourceError.Name, method.Name) + } + response := copyGRPCResponse(sourceError.Response) + response.Parent = endpoint + endpoint.GRPCErrors[errorIndex] = &expr.GRPCErrorExpr{ + ErrorExpr: methodError, + Name: sourceError.Name, + Response: response, + } + } + endpointPlan := &grpcEndpointPlan{ + expression: endpoint, + legacyStream: sourceEndpoint.LegacyStreamCompat(), + metadata: make(map[*expr.MappedAttributeExpr][]*grpcMetadataPlan), + } + service.GRPCEndpoints = append(service.GRPCEndpoints, endpoint) + result.endpoints[index] = endpointPlan + result.endpointByExpr[endpoint] = endpointPlan + } + return result, nil +} + +// copyGRPCMethod copies the method fields read by the gRPC transport. +func copyGRPCMethod(source *expr.MethodExpr, service *expr.ServiceExpr) *expr.MethodExpr { + method := &expr.MethodExpr{ + Name: source.Name, + Description: source.Description, + Payload: expr.DupAtt(source.Payload), + Result: expr.DupAtt(source.Result), + Requirements: copyGRPCRequirements(source.Requirements), + Service: service, + Meta: copyGRPCMeta(source.Meta), + Idempotent: source.Idempotent, + Stream: source.Stream, + StreamingPayload: expr.DupAtt(source.StreamingPayload), + } + switch { + case source.StreamingResult == nil: + method.StreamingResult = nil + case source.StreamingResult == source.Result: + method.StreamingResult = method.Result + default: + method.StreamingResult = expr.DupAtt(source.StreamingResult) + } + method.Errors = make([]*expr.ErrorExpr, len(source.Errors)) + for index, sourceError := range source.Errors { + method.Errors[index] = &expr.ErrorExpr{ + Name: sourceError.Name, + AttributeExpr: copyGRPCErrorAttribute(sourceError.AttributeExpr), + } + } + return method +} + +// copyGRPCErrorAttribute preserves Goa's built-in error type while copying +// fields from a custom error type. +func copyGRPCErrorAttribute(source *expr.AttributeExpr) *expr.AttributeExpr { + result := expr.DupAtt(source) + if expr.IsErrorResult(source.Type) { + result.Type = expr.ErrorResult + } + return result +} + +// copyGRPCResponse copies one success or error response. +func copyGRPCResponse(source *expr.GRPCResponseExpr) *expr.GRPCResponseExpr { + return &expr.GRPCResponseExpr{ + StatusCode: source.StatusCode, + Description: source.Description, + Message: expr.DupAtt(source.Message), + Headers: expr.DupMappedAtt(source.Headers), + Trailers: expr.DupMappedAtt(source.Trailers), + Meta: copyGRPCMeta(source.Meta), + } +} + +// copyGRPCRequirements copies security lists so later list edits cannot change +// generated metadata handling. +func copyGRPCRequirements(source []*expr.SecurityExpr) []*expr.SecurityExpr { + result := make([]*expr.SecurityExpr, len(source)) + for index, requirement := range source { + copy := expr.DupRequirement(requirement) + copy.Scopes = append([]string(nil), requirement.Scopes...) + for schemeIndex, scheme := range copy.Schemes { + scheme.Scopes = append([]*expr.ScopeExpr(nil), scheme.Scopes...) + scheme.Flows = append([]*expr.FlowExpr(nil), scheme.Flows...) + scheme.Meta = copyGRPCMeta(scheme.Meta) + copy.Schemes[schemeIndex] = scheme + } + result[index] = copy + } + return result +} + +// copyGRPCMeta copies every value list in one Meta map. +func copyGRPCMeta(source expr.MetaExpr) expr.MetaExpr { + if source == nil { + return nil + } + result := make(expr.MetaExpr, len(source)) + for name, values := range source { + result[name] = append([]string(nil), values...) + } + return result +} + +// planEndpointMetadata prepares every request, response, and legacy request +// metadata field used by one endpoint. +func planEndpointMetadata(endpoint *grpcEndpointPlan) error { + expression := endpoint.expression + groups := []struct { + mapped *expr.MappedAttributeExpr + service *expr.AttributeExpr + }{ + {expression.Metadata, expression.MethodExpr.Payload}, + {expression.Response.Headers, expression.MethodExpr.Result}, + {expression.Response.Trailers, expression.MethodExpr.Result}, + } + if endpoint.legacyStream { + endpoint.legacyMetadata = legacyRequestMetadata(expression) + groups = append(groups, struct { + mapped *expr.MappedAttributeExpr + service *expr.AttributeExpr + }{endpoint.legacyMetadata, expression.MethodExpr.Payload}) + } + for _, group := range groups { + plans, err := planMetadataFields(group.mapped, group.service) + if err != nil { + return err + } + endpoint.metadata[group.mapped] = plans + } + return nil +} + +// planMetadataFields prepares the Go value and both conversions for every field +// in one metadata group. +func planMetadataFields(mapped *expr.MappedAttributeExpr, service *expr.AttributeExpr) ([]*grpcMetadataPlan, error) { + var result []*grpcMetadataPlan + err := codegen.WalkMappedAttr(mapped, func(name, element string, required bool, attribute *expr.AttributeExpr) error { + wire := nativeMetadataAttribute(attribute) + scope := codegen.NewNameScope() + wireContext := codegen.NewAttributeContext(false, false, true, "", scope).Enter(wire) + serviceField := service + fieldType := service.Type + fieldName := codegen.Goify(name, true) + var pointer bool + if !expr.IsObject(service.Type) { + fieldName = "" + } else { + pointer = service.IsPrimitivePointer(name, true) + serviceField = service.Find(name) + fieldType = serviceField.Type + } + encode, err := codegen.NewTransformPlan(serviceField, wire, "", nil) + if err != nil { + return err + } + decode, err := codegen.NewTransformPlan(wire, serviceField, "", nil) + if err != nil { + return err + } + if len(encode.Helpers()) > 0 || len(decode.Helpers()) > 0 { + return fmt.Errorf("metadata field %q needs a separate conversion function", name) + } + result = append(result, &grpcMetadataPlan{ + name: name, + element: element, + required: required, + fieldName: fieldName, + fieldType: fieldType, + pointer: pointer, + serviceField: serviceField, + wire: wire, + scope: scope, + validation: codegen.AttributeValidationCode(wire, nil, wireContext, required, false, codegen.Goify(name, false), name), + encode: encode, + decode: decode, + }) + return nil + }) + return result, err +} + +// legacyRequestMetadata builds the metadata fields used by clients that send +// the first streamed payload through metadata. +func legacyRequestMetadata(endpoint *expr.GRPCEndpointExpr) *expr.MappedAttributeExpr { + payload := endpoint.MethodExpr.Payload + legacy := expr.DupMappedAtt(endpoint.Metadata) + metadataObject := expr.AsObject(legacy.Type) + if payloadObject := expr.AsObject(payload.Type); payloadObject != nil { + for _, named := range *payloadObject { + if metadataObject.Attribute(named.Name) == nil { + metadataObject.Set(named.Name, expr.DupAtt(named.Attribute)) + } + if payload.IsRequired(named.Name) { + legacy.Validation.AddRequired(named.Name) + } + } + } else { + metadataObject.Set("goa_payload", expr.DupAtt(payload)) + legacy.Validation.AddRequired("goa_payload") + } + return legacy +} + +// grpcServiceImportPaths records every package used by service values in gRPC +// client, server, type, and command-line files. +func grpcServiceImportPaths(generation *codegen.Generation, service *expr.GRPCServiceExpr) []string { + paths := make(map[string]struct{}) + seen := make(map[expr.UserType]struct{}) + for _, attribute := range grpcEndpointAttributes(service.GRPCEndpoints...) { + collectGRPCAttributeImportPaths(generation, attribute, paths, seen) + } + result := make([]string, 0, len(paths)) + for importPath := range paths { + result = append(result, importPath) + } + sort.Strings(result) + return result +} + +// collectGRPCAttributeImportPaths walks one service value and records named +// generated packages and explicit field packages. +func collectGRPCAttributeImportPaths(generation *codegen.Generation, attribute *expr.AttributeExpr, paths map[string]struct{}, seen map[expr.UserType]struct{}) { + if attribute == nil || attribute.Type == expr.Empty { + return + } + if _, spec := codegen.GetMetaType(attribute); spec != nil { + paths[spec.Path] = struct{}{} + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if location := codegen.UserTypeLocation(actual); location != nil { + paths[path.Join(generation.GenPkg(), location.RelImportPath)] = struct{}{} + } + if _, ok := seen[actual]; ok { + return + } + seen[actual] = struct{}{} + collectGRPCAttributeImportPaths(generation, actual.Attribute(), paths, seen) + case *expr.Object: + for _, named := range *actual { + collectGRPCAttributeImportPaths(generation, named.Attribute, paths, seen) + } + case *expr.Array: + collectGRPCAttributeImportPaths(generation, actual.ElemType, paths, seen) + case *expr.Map: + collectGRPCAttributeImportPaths(generation, actual.KeyType, paths, seen) + collectGRPCAttributeImportPaths(generation, actual.ElemType, paths, seen) + case *expr.Union: + for _, named := range actual.Values { + collectGRPCAttributeImportPaths(generation, named.Attribute, paths, seen) + } + } +} diff --git a/grpc/codegen/service_plan_imports_test.go b/grpc/codegen/service_plan_imports_test.go new file mode 100644 index 0000000000..d841bd9eaf --- /dev/null +++ b/grpc/codegen/service_plan_imports_test.go @@ -0,0 +1,69 @@ +// This file checks that copied gRPC values keep every package needed by their +// fields, even when the copies came from the same Goa type. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// TestCollectGRPCProtobufImportsVisitsEachCopiedType checks that one copied +// type cannot hide an external protobuf field used by another copy. +func TestCollectGRPCProtobufImportsVisitsEachCopiedType(t *testing.T) { + first, second := grpcImportCopies() + protobufField := expr.AsObject(second).Attribute("value") + protobufField.Meta["struct:field:proto"] = []string{ + "google.protobuf.Timestamp", + "google/protobuf/timestamp.proto", + "Timestamp", + "google.golang.org/protobuf/types/known/timestamppb", + } + + for _, roots := range [][2]expr.UserType{{first, second}, {second, first}} { + plan := &protobufServicePlan{messages: []*protobufEndpointMessages{{ + request: &expr.AttributeExpr{Type: roots[0]}, + response: &expr.AttributeExpr{Type: roots[1]}, + }}} + protoImports, goImports := collectGRPCProtobufImports(plan) + + require.Contains(t, protoImports, "google/protobuf/timestamp.proto") + require.Contains(t, goImports, codegen.NewImport( + "timestamppb", + "google.golang.org/protobuf/types/known/timestamppb", + )) + } +} + +// TestGRPCServiceImportPathsVisitsEachCopiedType checks that one copied type +// cannot hide a Go field package used by another copy. +func TestGRPCServiceImportPathsVisitsEachCopiedType(t *testing.T) { + first, second := grpcImportCopies() + goField := expr.AsObject(second).Attribute("value") + goField.Meta["struct:field:type"] = []string{"time.Time", "time"} + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + + for _, roots := range [][2]expr.UserType{{first, second}, {second, first}} { + service := &expr.GRPCServiceExpr{ + ServiceExpr: &expr.ServiceExpr{Name: "Imports"}, + GRPCEndpoints: []*expr.GRPCEndpointExpr{{MethodExpr: &expr.MethodExpr{ + Payload: &expr.AttributeExpr{Type: roots[0]}, + Result: &expr.AttributeExpr{Type: roots[1]}, + StreamingPayload: &expr.AttributeExpr{Type: expr.Empty}, + StreamingResult: &expr.AttributeExpr{Type: expr.Empty}, + }}}, + } + + require.Contains(t, grpcServiceImportPaths(generation, service), "time") + } +} + +// grpcImportCopies returns two independent copies of one Goa type. +func grpcImportCopies() (expr.UserType, expr.UserType) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + return expr.Dup(original).(expr.UserType), expr.Dup(original).(expr.UserType) +} diff --git a/grpc/codegen/streaming_errors_test.go b/grpc/codegen/streaming_errors_test.go index c1f938e7c1..b42f48e1c1 100644 --- a/grpc/codegen/streaming_errors_test.go +++ b/grpc/codegen/streaming_errors_test.go @@ -16,12 +16,12 @@ func TestStreamingWithErrors(t *testing.T) { cases := []struct { name string dsl func() - testFunc func(t *testing.T, code string) + testFunc func(t *testing.T, code string, services *ServicesData) }{ { name: "server streaming with custom errors", dsl: testdata.ServerStreamingWithCustomErrorsDSL, - testFunc: func(t *testing.T, code string) { + testFunc: func(t *testing.T, code string, services *ServicesData) { // Verify error decoding is present assert.Contains(t, code, "goagrpc.DecodeError(err)", "should decode errors from stream") @@ -36,17 +36,20 @@ func TestStreamingWithErrors(t *testing.T) { assert.Contains(t, code, "case *goapb.ErrorResponse:", "should handle generic goa errors") - // Verify proper error construction - assert.Contains(t, code, "NewServerStreamCustomErrorError(message", - "should construct custom error") - assert.Contains(t, code, "NewServerStreamValidationErrorError(message", - "should construct validation error") + // Each custom error uses the constructor chosen for the client package. + endpoint := services.Get("StreamingErrorService").Endpoint("ServerStream") + for _, errorData := range endpoint.Errors { + if errorData.Response.ClientConvert != nil { + name := errorData.Response.ClientConvert.Init.Declaration.Name() + assert.Contains(t, code, name+"(message", "should construct "+errorData.Name) + } + } }, }, { name: "bidirectional streaming with errors", dsl: testdata.BidirectionalStreamingRPCWithErrorsDSL, - testFunc: func(t *testing.T, code string) { + testFunc: func(t *testing.T, code string, _ *ServicesData) { // Bidirectional streaming with simple errors should still decode assert.Contains(t, code, "goagrpc.DecodeError(err)", "should decode errors from bidirectional stream") @@ -60,7 +63,7 @@ func TestStreamingWithErrors(t *testing.T) { t.Run(c.name, func(t *testing.T) { root := RunGRPCDSL(t, c.dsl) services := CreateGRPCServices(root) - clientfs := ClientFiles("", services) + clientfs := clientFiles(services) require.Greater(t, len(clientfs), 0) // Get recv method implementations @@ -75,7 +78,7 @@ func TestStreamingWithErrors(t *testing.T) { code := codeBuilder.String() // Run test-specific assertions - c.testFunc(t, code) + c.testFunc(t, code, services) }) } } @@ -94,7 +97,7 @@ func TestStreamingErrorsWithValidation(t *testing.T) { require.Greater(t, len(method.Errors), 0, "method should have errors defined") // Generate client code - clientfs := ClientFiles("", services) + clientfs := clientFiles(services) require.Greater(t, len(clientfs), 0) // Check recv implementations @@ -148,7 +151,7 @@ func TestStreamingErrorComparison(t *testing.T) { root := RunGRPCDSL(t, dsl) services := CreateGRPCServices(root) - clientfs := ClientFiles("", services) + clientfs := clientFiles(services) require.Greater(t, len(clientfs), 0, "should have client files") // Find unary and streaming code in different sections diff --git a/grpc/codegen/streaming_test.go b/grpc/codegen/streaming_test.go index 8a3c3b871b..2fdf7cd0bf 100644 --- a/grpc/codegen/streaming_test.go +++ b/grpc/codegen/streaming_test.go @@ -107,11 +107,11 @@ func TestStreaming(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - serverfs := ServerFiles("", services) + serverfs := serverFiles(services) if len(serverfs) < 2 { t.Fatalf("got %d server files, expected 2", len(serverfs)) } - clientfs := ClientFiles("", services) + clientfs := clientFiles(services) if len(clientfs) < 2 { t.Fatalf("got %d client files, expected 2", len(clientfs)) } @@ -154,11 +154,11 @@ func TestStreamingPayloadEnvelopeWithUnionPayload(t *testing.T) { root := RunGRPCDSL(t, testdata.ClientStreamingRPCWithUnionPayloadDSL) services := CreateGRPCServices(root) - clientfs := ClientFiles("", services) + clientfs := clientFiles(services) require.Len(t, clientfs, 2) - serverfs := ServerFiles("", services) + serverfs := serverFiles(services) require.Len(t, serverfs, 2) - protofs := ProtoFiles("", services) + protofs := protoFiles(services) require.Len(t, protofs, 1) requestEncoder := codegen.SectionsCode(t, clientfs[1].Section("request-encoder")) @@ -185,16 +185,16 @@ func TestStreamingPayloadEnvelopeWithUnionPayload(t *testing.T) { assert.Contains(t, proto, "MethodClientStreamingRPCWithUnionPayloadStreamItem stream_item") fpath := codegen.CreateTempFile(t, proto) - assert.NoError(t, protoc(defaultProtocCmd, fpath, nil)) + assert.NoError(t, protoc(defaultProtocCmd, fpath)) } func TestStreamingPayloadLegacyCompat(t *testing.T) { root := RunGRPCDSL(t, testdata.BidirectionalStreamingRPCWithPayloadLegacyCompatDSL) services := CreateGRPCServices(root) - serverfs := ServerFiles("", services) + serverfs := serverFiles(services) require.Len(t, serverfs, 2) - clientfs := ClientFiles("", services) + clientfs := clientFiles(services) require.Len(t, clientfs, 2) // The server stream tracks the protocol spoken by the client. @@ -216,14 +216,16 @@ func TestStreamingPayloadLegacyCompat(t *testing.T) { requestDecoder := codegen.SectionsCode(t, serverfs[1].Section("request-decoder")) assert.Contains(t, requestDecoder, "LegacyRequest(ctx, md)") assert.Contains(t, requestDecoder, `md.Get("a")`) - assert.Contains(t, requestDecoder, "PayloadFromMetadata(") + service := services.Get("ServiceBidirectionalStreamingRPCWithPayloadLegacyCompat") + legacyConstructor := service.Endpoints[0].Request.LegacyDecode.ServerConvert.Init.Declaration.Name() + assert.Contains(t, requestDecoder, legacyConstructor+"(") // Generated clients declare the envelope protocol in request metadata. requestEncoder := codegen.SectionsCode(t, clientfs[1].Section("request-encoder")) assert.Contains(t, requestEncoder, "goagrpc.StreamProtocolMetadataKey") // The wire contract for envelope clients is unchanged. - protofs := ProtoFiles("", services) + protofs := protoFiles(services) require.Len(t, protofs, 1) proto := sectionCode(t, protofs[0].SectionTemplates[1:]...) assert.Contains(t, proto, "oneof body") diff --git a/grpc/codegen/symbols.go b/grpc/codegen/symbols.go new file mode 100644 index 0000000000..c60ff813ad --- /dev/null +++ b/grpc/codegen/symbols.go @@ -0,0 +1,884 @@ +// This file chooses every Go name written into generated gRPC client and server +// packages. A definition and every call to it share one stored name. +package codegen + +import ( + "cmp" + "path" + "slices" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // grpcSymbols contains the names written for one gRPC service. + grpcSymbols struct { + clientStruct *codegen.NameDeclaration + clientInit *codegen.NameDeclaration + serverStruct *codegen.NameDeclaration + serverInit *codegen.NameDeclaration + endpoints map[*expr.GRPCEndpointExpr]*grpcEndpointSymbols + } + + // grpcEndpointSymbols contains the names written for one gRPC endpoint. + grpcEndpointSymbols struct { + clientStream *codegen.NameDeclaration + clientBuild *codegen.NameDeclaration + clientEncode *codegen.NameDeclaration + clientDecode *codegen.NameDeclaration + serverStream *codegen.NameDeclaration + serverHandler *codegen.NameDeclaration + serverDecode *codegen.NameDeclaration + serverEncode *codegen.NameDeclaration + legacyDecode *codegen.NameDeclaration + cliPayload *grpcConversion + clientInits map[grpcInitKey]*grpcConversion + serverInits map[grpcInitKey]*grpcConversion + } + + // grpcConversion contains one top-level conversion function and the extra + // conversion functions it calls for nested values. + grpcConversion struct { + declaration *codegen.NameDeclaration + transform *codegen.TransformPlan + pkg *codegen.GeneratedPackage + order grpcSymbolOrder + preferredName string + fullName string + serviceName string + messageName string + releasedNames []string + releasedResponseNames []string + side grpcPackageSide + bound bool + } + + // grpcTransform contains one private function name requested by a retained + // conversion plan. + grpcTransform struct { + plan *codegen.TransformPlan + helper codegen.TransformHelper + pkg *codegen.GeneratedPackage + order grpcSymbolOrder + preferredName string + fullName string + } + + // grpcConversionKey contains the package and types that decide one + // conversion function. endpoint is set when metadata changes its arguments. + grpcConversionKey struct { + pkg *codegen.GeneratedPackage + message *protobufMessageRecord + service expr.UserType + endpoint *expr.GRPCEndpointExpr + view string + proto bool + } + + // grpcSymbolID records which package, service, method, error, and field + // produced one Go name. These values decide collision order but do not appear + // in the name. + grpcSymbolID struct { + side grpcPackageSide + role grpcSymbolRole + api string + service string + method string + subject string + view string + path string + source string + target string + operation int + occurrence int + } + + // grpcSymbolOrder decides which item keeps an unsuffixed Go name when several + // items request the same name. + grpcSymbolOrder grpcSymbolID + + // grpcPackageSide identifies the generated package that contains a name. + grpcPackageSide uint8 + + // grpcSymbolRole identifies what a generated name defines. + grpcSymbolRole uint8 + + // grpcInitRole identifies one conversion constructor used by an endpoint. + grpcInitRole uint8 + + // grpcInitKey says which endpoint value uses a conversion. + grpcInitKey struct { + role grpcInitRole + subject string + view string + } +) + +const ( + grpcClientPackage grpcPackageSide = iota + 1 + grpcServerPackage +) + +const ( + grpcClientStructRole grpcSymbolRole = iota + 1 + grpcClientInitRole + grpcServerStructRole + grpcServerInitRole + grpcClientStreamRole + grpcClientBuildRole + grpcClientEncodeRole + grpcClientDecodeRole + grpcServerStreamRole + grpcServerHandlerRole + grpcServerDecodeRole + grpcServerEncodeRole + grpcLegacyDecodeRole + grpcConversionInitRole + grpcValidationRole + grpcTransformHelperRole +) + +const ( + grpcRequestInit grpcInitRole = iota + 1 + grpcResponseInit + grpcStreamingRequestInit + grpcStreamingResponseInit + grpcLegacyRequestInit + grpcErrorInit +) + +// collectGRPCSymbols requests the client and server names that can be chosen +// directly from the service and endpoint designs. +func collectGRPCSymbols(generation *codegen.Generation, input PlanInput, service *expr.GRPCServiceExpr, pathName string) (*grpcSymbols, error) { + clientPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + if err != nil { + return nil, err + } + serverPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", pathName, "server")) + if err != nil { + return nil, err + } + declare := func(pkg *codegen.GeneratedPackage, kind codegen.PackageNameKind, preferred string, visibility codegen.PackageNameVisibility, id grpcSymbolID) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(kind, preferred, visibility, grpcSymbolOrder(id)) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil + } + serviceID := grpcSymbolID{api: input.Root.API.Name, service: service.Name()} + symbols := &grpcSymbols{endpoints: make(map[*expr.GRPCEndpointExpr]*grpcEndpointSymbols)} + if symbols.clientStruct, err = declare(clientPackage, codegen.NameType, "Client", codegen.ExportedName, serviceID.client(grpcClientStructRole)); err != nil { + return nil, err + } + if symbols.clientInit, err = declare(clientPackage, codegen.NameFunction, "NewClient", codegen.ExportedName, serviceID.client(grpcClientInitRole)); err != nil { + return nil, err + } + if symbols.serverStruct, err = declare(serverPackage, codegen.NameType, "Server", codegen.ExportedName, serviceID.server(grpcServerStructRole)); err != nil { + return nil, err + } + if symbols.serverInit, err = declare(serverPackage, codegen.NameFunction, "New", codegen.ExportedName, serviceID.server(grpcServerInitRole)); err != nil { + return nil, err + } + for _, endpoint := range service.GRPCEndpoints { + names, err := input.Service.HTTPMethodNames(endpoint.MethodExpr) + if err != nil { + return nil, err + } + id := serviceID.withMethod(endpoint.Name()) + endpointSymbols := &grpcEndpointSymbols{ + clientInits: make(map[grpcInitKey]*grpcConversion), + serverInits: make(map[grpcInitKey]*grpcConversion), + } + endpointSymbols.clientBuild, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"Func", codegen.ExportedName, id.client(grpcClientBuildRole)) + if err != nil { + return nil, err + } + if endpoint.MethodExpr.Payload.Type != expr.Empty { + endpointSymbols.clientEncode, err = declare(clientPackage, codegen.NameFunction, "Encode"+names.Method+"Request", codegen.ExportedName, id.client(grpcClientEncodeRole)) + if err != nil { + return nil, err + } + endpointSymbols.serverDecode, err = declare(serverPackage, codegen.NameFunction, "Decode"+names.Method+"Request", codegen.ExportedName, id.server(grpcServerDecodeRole)) + if err != nil { + return nil, err + } + } + if endpoint.MethodExpr.Result.Type != expr.Empty || endpoint.MethodExpr.IsStreaming() { + endpointSymbols.clientDecode, err = declare(clientPackage, codegen.NameFunction, "Decode"+names.Method+"Response", codegen.ExportedName, id.client(grpcClientDecodeRole)) + if err != nil { + return nil, err + } + } + endpointSymbols.serverEncode, err = declare(serverPackage, codegen.NameFunction, "Encode"+names.Method+"Response", codegen.ExportedName, id.server(grpcServerEncodeRole)) + if err != nil { + return nil, err + } + endpointSymbols.serverHandler, err = declare(serverPackage, codegen.NameFunction, "New"+names.Method+"Handler", codegen.ExportedName, id.server(grpcServerHandlerRole)) + if err != nil { + return nil, err + } + if endpoint.MethodExpr.IsStreaming() { + endpointSymbols.clientStream, err = declare(clientPackage, codegen.NameType, names.ClientStream, codegen.ExportedName, id.client(grpcClientStreamRole)) + if err != nil { + return nil, err + } + endpointSymbols.serverStream, err = declare(serverPackage, codegen.NameType, names.ServerStream, codegen.ExportedName, id.server(grpcServerStreamRole)) + if err != nil { + return nil, err + } + } + if endpoint.LegacyStreamCompat() { + preferred := "decode" + names.Method + "LegacyRequest" + endpointSymbols.legacyDecode, err = declare(serverPackage, codegen.NameFunction, preferred, codegen.UnexportedName, id.server(grpcLegacyDecodeRole)) + if err != nil { + return nil, err + } + } + symbols.endpoints[endpoint] = endpointSymbols + } + return symbols, nil +} + +// planGRPCTransforms records each conversion and requests the names of any +// nested conversion functions it will call. It does not read those names yet. +func planGRPCTransforms( + generation *codegen.Generation, + input PlanInput, + service *expr.GRPCServiceExpr, + protobuf *protobufServicePlan, + symbols *grpcSymbols, + conversions map[grpcConversionKey]*grpcConversion, + helpers *[]*grpcTransform, + pathName string, +) error { + clientPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + serverPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", pathName, "server")) + for index, endpoint := range service.GRPCEndpoints { + messages := protobuf.messages[index] + endpointSymbols := symbols.endpoints[endpoint] + result := endpoint.MethodExpr.Result + _, viewed := result.Type.(*expr.ResultTypeExpr) + if viewed { + projected, err := input.Service.ProjectedResult(endpoint.MethodExpr) + if err != nil { + return err + } + result = projected + } + conversionFor := func(side grpcPackageSide, source, target *expr.AttributeExpr, proto, endpointSpecific bool, view string) (*grpcConversion, error) { + pkg := clientPackage + if side == grpcServerPackage { + pkg = serverPackage + } + protobufAttribute := source + serviceAttribute := target + if proto { + protobufAttribute = target + serviceAttribute = source + } + message := protobuf.catalog.messageRecord(protobufAttribute) + var serviceType expr.UserType + if userType, ok := serviceAttribute.Type.(expr.UserType); ok { + serviceType = userType.Origin() + } + conversionKey := grpcConversionKey{ + pkg: pkg, + message: message, + service: serviceType, + view: view, + proto: proto, + } + if endpointSpecific { + conversionKey.endpoint = endpoint + } + conversion := conversions[conversionKey] + preferred, fullName, serviceName, messageName := grpcConversionNames(serviceAttribute, message, proto) + viewKey := grpcInitKey{view: view} + preferred = grpcViewedConversionName(endpoint.MethodExpr, viewKey, preferred) + fullName = grpcViewedConversionName(endpoint.MethodExpr, viewKey, fullName) + id := grpcSymbolID{ + side: side, + role: grpcConversionInitRole, + api: input.Root.API.Name, + service: service.Name(), + subject: serviceName, + view: conversionKey.view, + path: messageName, + operation: grpcConversionDirection(proto), + } + if endpointSpecific { + id.method = endpoint.Name() + } + if conversion == nil { + transform, err := newGRPCTransformPlan(source, target, proto, protobuf) + if err != nil { + return nil, err + } + for _, helper := range transform.Helpers() { + helperID := id + helperID.role = grpcTransformHelperRole + helperID.source = grpcTransformTypeName(helper.Source) + helperID.target = grpcTransformTypeName(helper.Target) + helperID.occurrence = helper.Occurrence + methodName := "" + if endpointSpecific { + methodName = endpoint.Name() + } + preferredName, fullName := grpcTransformHelperNames(helper, proto, serviceName, messageName, methodName) + preferredName = grpcViewedConversionName(endpoint.MethodExpr, viewKey, preferredName) + fullName = grpcViewedConversionName(endpoint.MethodExpr, viewKey, fullName) + *helpers = append(*helpers, &grpcTransform{ + plan: transform, + helper: helper, + pkg: pkg, + order: grpcSymbolOrder(helperID), + preferredName: preferredName, + fullName: fullName, + }) + } + conversion = &grpcConversion{ + transform: transform, + pkg: pkg, + order: grpcSymbolOrder(id), + preferredName: preferred, + fullName: fullName, + serviceName: serviceName, + messageName: messageName, + side: side, + } + conversions[conversionKey] = conversion + } + return conversion, nil + } + plan := func(side grpcPackageSide, key grpcInitKey, source, target *expr.AttributeExpr, proto, endpointSpecific bool) error { + conversion, err := conversionFor(side, source, target, proto, endpointSpecific, key.view) + if err != nil { + return err + } + releasedName := releasedGRPCConversionName(endpoint, key, source, target, proto, conversion) + conversion.releasedNames = append(conversion.releasedNames, releasedName) + if key.role == grpcResponseInit && !slices.Contains(conversion.releasedResponseNames, releasedName) { + conversion.releasedResponseNames = append(conversion.releasedResponseNames, releasedName) + } + inits := endpointSymbols.clientInits + if side == grpcServerPackage { + inits = endpointSymbols.serverInits + } + inits[key] = conversion + return nil + } + if endpoint.MethodExpr.Payload.Type != expr.Empty { + if err := plan(grpcServerPackage, grpcInitKey{role: grpcRequestInit}, messages.request, endpoint.MethodExpr.Payload, false, !endpoint.Metadata.IsEmpty()); err != nil { + return err + } + cliConversion, err := conversionFor(grpcClientPackage, messages.request, endpoint.MethodExpr.Payload, false, false, "") + if err != nil { + return err + } + endpointSymbols.cliPayload = cliConversion + } + if !(endpoint.MethodExpr.IsPayloadStreaming() && isEmpty(endpoint.Request.Type)) { + if err := plan(grpcClientPackage, grpcInitKey{role: grpcRequestInit}, endpoint.MethodExpr.Payload, messages.request, true, false); err != nil { + return err + } + } + if viewed { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewResult, err := grpcResultForView(result, view) + if err != nil { + return err + } + if err := plan(grpcServerPackage, grpcInitKey{role: grpcResponseInit, view: view}, viewResult, messages.response, true, false); err != nil { + return err + } + } + } else if err := plan(grpcServerPackage, grpcInitKey{role: grpcResponseInit}, result, messages.response, true, false); err != nil { + return err + } + if endpoint.MethodExpr.Result.Type != expr.Empty && !endpoint.MethodExpr.IsStreaming() { + responseMetadata := !endpoint.Response.Headers.IsEmpty() || !endpoint.Response.Trailers.IsEmpty() + if viewed { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewResult, err := grpcResultForView(result, view) + if err != nil { + return err + } + if err := plan(grpcClientPackage, grpcInitKey{role: grpcResponseInit, view: view}, messages.response, viewResult, false, responseMetadata); err != nil { + return err + } + } + } else if err := plan(grpcClientPackage, grpcInitKey{role: grpcResponseInit}, messages.response, result, false, responseMetadata); err != nil { + return err + } + } + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + key := grpcInitKey{role: grpcStreamingRequestInit} + if err := plan(grpcServerPackage, key, messages.streamingRequest, endpoint.MethodExpr.StreamingPayload, false, false); err != nil { + return err + } + if err := plan(grpcClientPackage, key, endpoint.MethodExpr.StreamingPayload, messages.streamingRequest, true, false); err != nil { + return err + } + } + if endpoint.MethodExpr.Result.Type != expr.Empty && endpoint.MethodExpr.IsStreaming() { + if viewed { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewResult, err := grpcResultForView(result, view) + if err != nil { + return err + } + key := grpcInitKey{role: grpcStreamingResponseInit, view: view} + if err := plan(grpcServerPackage, key, viewResult, messages.response, true, false); err != nil { + return err + } + } + } else { + key := grpcInitKey{role: grpcStreamingResponseInit} + if err := plan(grpcServerPackage, key, result, messages.response, true, false); err != nil { + return err + } + } + if viewed { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewResult, err := grpcResultForView(result, view) + if err != nil { + return err + } + key := grpcInitKey{role: grpcStreamingResponseInit, view: view} + if err := plan(grpcClientPackage, key, messages.response, viewResult, false, false); err != nil { + return err + } + } + } else { + key := grpcInitKey{role: grpcStreamingResponseInit} + if err := plan(grpcClientPackage, key, messages.response, result, false, false); err != nil { + return err + } + } + } + if endpoint.LegacyStreamCompat() && expr.IsObject(endpoint.MethodExpr.Payload.Type) { + key := grpcInitKey{role: grpcLegacyRequestInit} + if err := plan(grpcServerPackage, key, &expr.AttributeExpr{Type: expr.Empty}, endpoint.MethodExpr.Payload, false, true); err != nil { + return err + } + } + for _, grpcError := range endpoint.GRPCErrors { + message := messages.errors[grpcError.Name] + if message == nil { + continue + } + key := grpcInitKey{role: grpcErrorInit, subject: grpcError.Name} + if err := plan(grpcServerPackage, key, grpcError.AttributeExpr, message, true, false); err != nil { + return err + } + if err := plan(grpcClientPackage, key, message, grpcError.AttributeExpr, false, false); err != nil { + return err + } + } + } + return nil +} + +// grpcResultViews returns the views the server may send for method. A view set +// in the design is the only possible value. Otherwise callers may select any +// view declared by the result type. +func grpcResultViews(method *expr.MethodExpr) []string { + if method.Result.Meta != nil { + if view, ok := method.Result.Meta.Last(expr.ViewMetaKey); ok { + return []string{view} + } + } + resultType := method.Result.Type.(*expr.ResultTypeExpr) + views := make([]string, len(resultType.Views)) + for index, view := range resultType.Views { + views[index] = view.Name + } + return views +} + +// grpcResultForView keeps the generated projected Go type but limits the +// conversion plan to fields included in view. +func grpcResultForView(result *expr.AttributeExpr, view string) (*expr.AttributeExpr, error) { + resultType := result.Type.(*expr.ResultTypeExpr) + selected, err := expr.Project(resultType, view) + if err != nil { + return nil, err + } + selectedResult := expr.DupAtt(result) + selectedResult.Type = selected + return grpcViewAttribute(result, selectedResult, make(map[expr.UserType]expr.UserType)), nil +} + +// grpcViewAttribute copies only the selected fields while reusing the Go types +// already generated for the complete result. +func grpcViewAttribute(full, selected *expr.AttributeExpr, seen map[expr.UserType]expr.UserType) *expr.AttributeExpr { + filtered := expr.DupAtt(selected) + switch selectedType := selected.Type.(type) { + case expr.UserType: + if existing, ok := seen[selectedType]; ok { + filtered.Type = existing + return filtered + } + fullType := full.Type.(expr.UserType) + copy := fullType.Dup(expr.DupAtt(selectedType.Attribute())) + seen[selectedType] = copy + copy.SetAttribute(grpcViewAttribute(fullType.Attribute(), selectedType.Attribute(), seen)) + filtered.Type = copy + case *expr.Array: + fullType := full.Type.(*expr.Array) + filtered.Type = &expr.Array{ + ElemType: grpcViewAttribute(fullType.ElemType, selectedType.ElemType, seen), + NonNullableElems: selectedType.NonNullableElems, + } + case *expr.Map: + fullType := full.Type.(*expr.Map) + filtered.Type = &expr.Map{ + KeyType: grpcViewAttribute(fullType.KeyType, selectedType.KeyType, seen), + ElemType: grpcViewAttribute(fullType.ElemType, selectedType.ElemType, seen), + } + case *expr.Object: + fullType := full.Type.(*expr.Object) + object := make(expr.Object, 0, len(*selectedType)) + for _, field := range *selectedType { + object = append(object, &expr.NamedAttributeExpr{ + Name: field.Name, + Attribute: grpcViewAttribute(fullType.Attribute(field.Name), field.Attribute, seen), + }) + } + filtered.Type = &object + case *expr.Union: + fullType := full.Type.(*expr.Union) + union := &expr.Union{ + TypeName: selectedType.TypeName, + TypeKey: selectedType.TypeKey, + ValueKey: selectedType.ValueKey, + Values: make([]*expr.NamedAttributeExpr, 0, len(selectedType.Values)), + } + for index, branch := range selectedType.Values { + union.Values = append(union.Values, &expr.NamedAttributeExpr{ + Name: branch.Name, + Attribute: grpcViewAttribute(fullType.Values[index].Attribute, branch.Attribute, seen), + }) + } + filtered.Type = union + } + return filtered +} + +// declareGRPCTransforms keeps a released response name when one method owns +// it. Conversions shared by several methods use names based on their types. +func declareGRPCTransforms(conversions map[grpcConversionKey]*grpcConversion, helpers []*grpcTransform) error { + type nameKey struct { + pkg *codegen.GeneratedPackage + name string + } + counts := make(map[nameKey]int) + for _, conversion := range conversions { + if len(conversion.releasedNames) > 1 { + counts[nameKey{pkg: conversion.pkg, name: conversion.preferredName}]++ + } + } + for _, conversion := range conversions { + if len(conversion.releasedNames) == 0 { + continue + } + name := conversion.releasedNames[0] + useResponseName := len(conversion.releasedResponseNames) == 1 + useTypeName := !useResponseName && len(conversion.releasedNames) > 1 + if useResponseName { + name = conversion.releasedResponseNames[0] + } else if useTypeName { + name = conversion.preferredName + } + if useTypeName && counts[nameKey{pkg: conversion.pkg, name: name}] > 1 { + name = conversion.fullName + } + declaration := codegen.NewPreferredName(codegen.NameFunction, name, codegen.ExportedName, conversion.order) + if err := conversion.pkg.DeclareName(declaration); err != nil { + return err + } + conversion.declaration = declaration + } + helpersByName := make(map[nameKey]int) + for _, helper := range helpers { + helpersByName[nameKey{pkg: helper.pkg, name: helper.preferredName}]++ + } + for _, helper := range helpers { + name := helper.preferredName + if helpersByName[nameKey{pkg: helper.pkg, name: name}] > 1 { + name = helper.fullName + } + declaration := codegen.NewPreferredName(codegen.NameFunction, name, codegen.UnexportedName, helper.order) + if err := helper.pkg.DeclareName(declaration); err != nil { + return err + } + if err := helper.plan.BindHelperDeclaration(helper.helper.ID, declaration); err != nil { + return err + } + } + return nil +} + +// releasedGRPCConversionName returns the constructor name generated before +// conversions shared by several methods were combined. +func releasedGRPCConversionName(endpoint *expr.GRPCEndpointExpr, key grpcInitKey, source, target *expr.AttributeExpr, proto bool, conversion *grpcConversion) string { + method := codegen.Goify(endpoint.Name(), true) + switch key.role { + case grpcRequestInit: + if !proto { + return "New" + method + "Payload" + } + return "NewProto" + conversion.messageName + case grpcResponseInit: + if !proto { + return grpcViewedConversionName(endpoint.MethodExpr, key, "New"+method+"Result") + } + name := conversion.messageName + bodyIsStruct := expr.IsUnion(target.Type) + if object := expr.AsObject(target.Type); object != nil { + bodyIsStruct = len(*object) > 0 + } + if !bodyIsStruct && key.view == "" { + name = conversion.serviceName + } + return grpcViewedConversionName(endpoint.MethodExpr, key, "NewProto"+name) + case grpcStreamingRequestInit, grpcStreamingResponseInit: + name := releasedGRPCStreamConversionName(source, target, proto, conversion) + return grpcViewedConversionName(endpoint.MethodExpr, key, name) + case grpcLegacyRequestInit: + return "New" + method + "PayloadFromMetadata" + case grpcErrorInit: + return "New" + method + codegen.Goify(key.subject, true) + "Error" + default: + panic("unknown gRPC conversion role") + } +} + +// grpcViewedConversionName keeps the existing constructor name for the only +// view selected by a design. When callers choose a view, additional +// constructors include the view name. +func grpcViewedConversionName(method *expr.MethodExpr, key grpcInitKey, name string) string { + if key.view == "" || key.view == expr.DefaultView { + return name + } + if method.Result.Meta != nil { + if _, fixed := method.Result.Meta.Last(expr.ViewMetaKey); fixed { + return name + } + } + return name + codegen.Goify(key.view, true) +} + +// releasedGRPCStreamConversionName returns the name used by released Goa +// versions for a conversion of one streamed value. +func releasedGRPCStreamConversionName(source, target *expr.AttributeExpr, proto bool, conversion *grpcConversion) string { + name := "New" + if proto { + name += "Proto" + } + if _, ok := source.Type.(expr.UserType); ok { + if proto { + name += conversion.serviceName + } else { + name += conversion.messageName + } + } + targetName := conversion.serviceName + if proto { + targetName = conversion.messageName + } + if !expr.IsObject(target.Type) && !expr.IsUnion(target.Type) { + targetName = conversion.messageName + if proto { + targetName = conversion.serviceName + } + } + return name + targetName +} + +// grpcConversionNames returns the short and complete constructor names and +// the type names used to order colliding requests. +func grpcConversionNames(serviceAttribute *expr.AttributeExpr, message *protobufMessageRecord, proto bool) (string, string, string, string) { + var messageName string + if message != nil { + messageName = message.plannedName + } + serviceName := messageName + if userType, ok := serviceAttribute.Type.(expr.UserType); ok && serviceAttribute.Type != expr.Empty { + serviceName = codegen.Goify(userType.Name(), true) + } + if serviceName == "" { + serviceName = codegen.Goify(serviceAttribute.Type.Name(), true) + } + name := "New" + serviceName + fullName := "New" + serviceName + "FromProto" + messageName + if message == nil { + fullName = "New" + serviceName + "FromMetadata" + } + if proto { + name = "NewProto" + serviceName + fullName = "NewProto" + messageName + "From" + serviceName + } + return name, fullName, serviceName, messageName +} + +// grpcTransformHelperNames returns the short nested-type name and the complete +// name that also identifies the outer conversion. +func grpcTransformHelperNames(helper codegen.TransformHelper, proto bool, serviceName, messageName, methodName string) (string, string) { + source := grpcTransformTypeName(helper.Source) + target := grpcTransformTypeName(helper.Target) + if proto { + return codegen.Goify("transform"+source+"ToProto"+target, false), + codegen.Goify("transform"+methodName+serviceName+source+"ToProto"+messageName+target, false) + } + return codegen.Goify("transformProto"+source+"To"+target, false), + codegen.Goify("transform"+methodName+"Proto"+messageName+source+"To"+serviceName+target, false) +} + +// grpcTransformTypeName returns the declared type name used in a private +// conversion function signature. +func grpcTransformTypeName(attribute *expr.AttributeExpr) string { + if userType, ok := attribute.Type.(expr.UserType); ok { + return codegen.Goify(userType.Name(), true) + } + return codegen.Goify(attribute.Type.Name(), true) +} + +// grpcConversionDirection returns the fixed number used to order conversions +// to and from protobuf messages. +func grpcConversionDirection(proto bool) int { + if proto { + return 1 + } + return 2 +} + +// planGRPCValidations records each validation function in the client or server +// package that writes it. +func planGRPCValidations(generation *codegen.Generation, input PlanInput, service *expr.GRPCServiceExpr, protobuf *protobufServicePlan, pathName string) error { + clientPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + serverPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", pathName, "server")) + for index, endpoint := range service.GRPCEndpoints { + messages := protobuf.messages[index] + source := protobufValidationSource{ + api: input.Root.API.Name, + service: service.Name(), + method: endpoint.Name(), + } + if protobuf.catalog.messageRecord(messages.request) != nil { + request := source + request.role = protobufRequestValidation + protobuf.catalog.collectValidation(messages.request, validateServer, request, "message", "message") + } + if protobuf.catalog.messageRecord(messages.response) != nil { + response := source + response.role = protobufResponseValidation + protobuf.catalog.collectValidation(messages.response, validateClient, response, "message", "message") + } + for _, grpcError := range endpoint.GRPCErrors { + message := messages.errors[grpcError.Name] + if message == nil { + continue + } + errorSource := source + errorSource.error = grpcError.Name + errorSource.role = protobufErrorValidation + protobuf.catalog.collectValidation(message, validateClient, errorSource, "errmsg", "errmsg") + } + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + stream := source + stream.role = protobufStreamingRequestValidation + protobuf.catalog.collectValidation(messages.streamingRequest, validateServer, stream, "stream", "stream") + } + } + for _, validator := range protobuf.catalog.validators { + pkg := clientPackage + side := grpcClientPackage + if validator.side == validateServer { + pkg = serverPackage + side = grpcServerPackage + } + id := grpcSymbolID{ + side: side, + role: grpcValidationRole, + api: validator.source.api, + service: validator.source.service, + method: validator.source.method, + subject: validator.source.error, + path: validator.source.path, + operation: int(validator.source.role), + } + validator.declaration = codegen.NewPreferredName( + codegen.NameFunction, + "Validate"+validator.message.plannedName, + codegen.ExportedName, + grpcSymbolOrder(id), + ) + if err := pkg.DeclareName(validator.declaration); err != nil { + return err + } + } + return nil +} + +// newGRPCTransformPlan copies the protobuf input or output type, then records +// every nested conversion function that the generated code will call. +func newGRPCTransformPlan(source, target *expr.AttributeExpr, proto bool, protobuf *protobufServicePlan) (*codegen.TransformPlan, error) { + prefix := "protobuf" + if proto { + original := target + target = expr.DupAtt(target) + protobuf.bindAttributeCopy(original, target) + removeMeta(target) + prefix = "svc" + } else { + original := source + source = expr.DupAtt(source) + protobuf.bindAttributeCopy(original, source) + removeMeta(source) + } + return codegen.NewTransformPlan(source, target, prefix, protoHooks(proto)) +} + +// ComparePackageName orders generated declarations by package, purpose, API, +// service, method, error, and field. +func (left grpcSymbolOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(grpcSymbolOrder) + return cmp.Or( + cmp.Compare(left.side, right.side), + cmp.Compare(left.role, right.role), + strings.Compare(left.api, right.api), + strings.Compare(left.service, right.service), + strings.Compare(left.method, right.method), + strings.Compare(left.subject, right.subject), + strings.Compare(left.view, right.view), + strings.Compare(left.path, right.path), + strings.Compare(left.source, right.source), + strings.Compare(left.target, right.target), + cmp.Compare(left.operation, right.operation), + cmp.Compare(left.occurrence, right.occurrence), + ) +} + +// withMethod returns the declaration details for one method in the same +// service. +func (id grpcSymbolID) withMethod(method string) grpcSymbolID { + id.method = method + return id +} + +// client selects the generated client package and the kind of declaration. +func (id grpcSymbolID) client(role grpcSymbolRole) grpcSymbolID { + id.side = grpcClientPackage + id.role = role + return id +} + +// server selects the generated server package and the kind of declaration. +func (id grpcSymbolID) server(role grpcSymbolRole) grpcSymbolID { + id.side = grpcServerPackage + id.role = role + return id +} diff --git a/grpc/codegen/templates.go b/grpc/codegen/templates.go index ff9574d22f..e17610a963 100644 --- a/grpc/codegen/templates.go +++ b/grpc/codegen/templates.go @@ -63,8 +63,8 @@ const ( // Partial template constants const ( - grpcConvertTypeToStringP = "convert_type_to_string" - grpcConvertStringToTypeP = "convert_string_to_type" + grpcConvertStringToTypeP = "convert_string_to_type" + grpcTypeToStringExpressionP = "type_to_string_expression" ) // Common template constants diff --git a/grpc/codegen/templates/client_endpoint_init.go.tpl b/grpc/codegen/templates/client_endpoint_init.go.tpl index 9d4df36299..06d1425b4f 100644 --- a/grpc/codegen/templates/client_endpoint_init.go.tpl +++ b/grpc/codegen/templates/client_endpoint_init.go.tpl @@ -1,15 +1,15 @@ {{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) }} -{{ printf "%s calls the %q function in %s.%s interface." .Method.VarName .Method.VarName .PkgName .ClientInterface | comment }} -func (c *{{ .ClientStruct }}) {{ .Method.VarName }}() goa.Endpoint { +{{ printf "%s calls the %q function in %s.%s interface." .Method.VarName .Method.VarName .ClientProtobufPkgName .ClientInterface | comment }} +func (c *{{ .ClientStructDeclaration.Name }}) {{ .Method.VarName }}() goa.Endpoint { {{- if $retry }} endpoint := func(ctx context.Context, v any) (any, error) { {{- else }} return func(ctx context.Context, v any) (any, error) { {{- end }} inv := goagrpc.NewInvoker( - Build{{ .Method.VarName }}Func(c.grpccli, c.opts...), - {{ if .PayloadRef }}Encode{{ .Method.VarName }}Request{{ else }}nil{{ end }}, - {{ if or .ResultRef .ClientStream }}Decode{{ .Method.VarName }}Response{{ else }}nil{{ end }}) + {{ .ClientBuildDeclaration.Name }}(c.grpccli, c.opts...), + {{ if .PayloadRef }}{{ .ClientEncodeDeclaration.Name }}{{ else }}nil{{ end }}, + {{ if or .ResultRef .ClientStream }}{{ .ClientDecodeDeclaration.Name }}{{ else }}nil{{ end }}) res, err := inv.Invoke(ctx, v) if err != nil { {{- if .Errors }} @@ -19,11 +19,11 @@ func (c *{{ .ClientStruct }}) {{ .Method.VarName }}() goa.Endpoint { {{- if .Response.ClientConvert }} case {{ .Response.ClientConvert.SrcRef }}: {{- if .Response.ClientConvert.Validation }} - if err := {{ .Response.ClientConvert.Validation.Name }}(message); err != nil { + if err := {{ .Response.ClientConvert.Validation.Declaration.Name }}(message); err != nil { return nil, err } {{- end }} - return nil, {{ .Response.ClientConvert.Init.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + return nil, {{ .Response.ClientConvert.Init.Declaration.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- end }} {{- end }} case *goapb.ErrorResponse: diff --git a/grpc/codegen/templates/client_init.go.tpl b/grpc/codegen/templates/client_init.go.tpl index 0f4ad98adf..bc7ff086cf 100644 --- a/grpc/codegen/templates/client_init.go.tpl +++ b/grpc/codegen/templates/client_init.go.tpl @@ -1,6 +1,6 @@ -{{ printf "New%s instantiates gRPC client for all the %s service servers." .ClientStruct .Service.Name | comment }} -func New{{ .ClientStruct }}(cc *grpc.ClientConn, opts ...grpc.CallOption) *{{ .ClientStruct }} { - return &{{ .ClientStruct }}{ +{{ printf "%s instantiates gRPC client for all the %s service servers." .ClientInitDeclaration.Name .Service.Name | comment }} +func {{ .ClientInitDeclaration.Name }}(cc *grpc.ClientConn, opts ...grpc.CallOption) *{{ .ClientStructDeclaration.Name }} { + return &{{ .ClientStructDeclaration.Name }}{ grpccli: {{ .ClientInterfaceInit }}(cc), opts: opts, } diff --git a/grpc/codegen/templates/client_struct.go.tpl b/grpc/codegen/templates/client_struct.go.tpl index e167dd4183..e15620871d 100644 --- a/grpc/codegen/templates/client_struct.go.tpl +++ b/grpc/codegen/templates/client_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s lists the service endpoint gRPC clients." .ClientStruct | comment }} -type {{ .ClientStruct }} struct { - grpccli {{ .PkgName }}.{{ .ClientInterface }} +{{ printf "%s lists the service endpoint gRPC clients." .ClientStructDeclaration.Name | comment }} +type {{ .ClientStructDeclaration.Name }} struct { + grpccli {{ .ClientProtobufPkgName }}.{{ .ClientInterface }} opts []grpc.CallOption } diff --git a/grpc/codegen/templates/do_grpc_cli.go.tpl b/grpc/codegen/templates/do_grpc_cli.go.tpl index 706280ddec..02997fe62f 100644 --- a/grpc/codegen/templates/do_grpc_cli.go.tpl +++ b/grpc/codegen/templates/do_grpc_cli.go.tpl @@ -1,29 +1,80 @@ -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { +{{- if hasAnyInputStreams .Services }} + switch flag.Arg(0) { + {{- range .Services }} + {{- if hasInputStreams . }} + case {{ printf "%q" (kebab .Service.Name) }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + {{- if streamsInput .Method }} + case {{ printf "%q" (kebab .Method.Name) }}: + return errors.New({{ printf "%q" (printf "example client does not support streamed input for service %q method %q" .ServiceName .Method.Name) }}) + {{- end }} + {{- end }} + } + {{- end }} + {{- end }} + } +{{- end }} conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) - } + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() {{- range .Services }} {{- if .Service.ClientInterceptors }} {{ .Service.VarName }}Interceptors := {{ $.InterceptorsPkg }}.New{{ .Service.StructName }}ClientInterceptors() {{- end }} {{- end }} - return cli.ParseEndpoint( +{{- if hasRunnable .Services }} + endpoint, payload, err := {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- else }} + _, _, err = {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- end }} conn, {{- range .Services }} {{- if .Service.ClientInterceptors }} {{ .Service.VarName }}Interceptors, {{- end }} -{{- end }} + {{- end }} ) -} + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } -{{ if eq .DefaultTransport.Type "grpc" }} -func grpcUsageCommands() []string { - return cli.UsageCommands() +{{ if hasRunnable .Services }} + switch flag.Arg(0) { + {{- range .Services }} + {{- if hasRunnableService . }} + case {{ printf "%q" (kebab .Service.Name) }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + {{- if not (streamsInput .Method) }} + case {{ printf "%q" (kebab .Method.Name) }}: + {{- if streamsOutput .Method }} + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.({{ .ServicePkgName }}.{{ .Method.ClientStream.Interface }}) + return writeStreamResults(ctx, stdout, stream.{{ .Method.ClientStream.RecvWithContextName }}) + {{- else }} + return writeEndpointResult(ctx, stdout, endpoint, payload) + {{- end }} + {{- end }} + {{- end }} + } + {{- end }} + {{- end }} + } + {{- end }} + panic("parsed gRPC command has no generated result writer") } +{{ if eq .DefaultTransport.Type "grpc" }} func grpcUsageExamples() string { - return cli.UsageExamples() + return {{ .CLIPkg }}.{{ .Parser.UsageExamples.Name }}() } {{- end }} diff --git a/grpc/codegen/templates/grpc_handler_init.go.tpl b/grpc/codegen/templates/grpc_handler_init.go.tpl index 8c962c6298..1f951530ed 100644 --- a/grpc/codegen/templates/grpc_handler_init.go.tpl +++ b/grpc/codegen/templates/grpc_handler_init.go.tpl @@ -1,7 +1,7 @@ -{{ printf "New%sHandler creates a gRPC handler which serves the %q service %q endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func New{{ .Method.VarName }}Handler(endpoint goa.Endpoint, h goagrpc.{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler) goagrpc.{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler { +{{ printf "%s creates a gRPC handler which serves the %q service %q endpoint." .ServerHandlerDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ServerHandlerDeclaration.Name }}(endpoint goa.Endpoint, h goagrpc.{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler) goagrpc.{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler { if h == nil { - h = goagrpc.New{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler(endpoint, {{ if .Method.Payload }}Decode{{ .Method.VarName }}Request{{ else }}nil{{ end }}{{ if not .ServerStream }}, Encode{{ .Method.VarName }}Response{{ end }}) + h = goagrpc.New{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler(endpoint, {{ if .Method.Payload }}{{ .ServerDecodeDeclaration.Name }}{{ else }}nil{{ end }}{{ if not .ServerStream }}, {{ .ServerEncodeDeclaration.Name }}{{ end }}) } return h } diff --git a/grpc/codegen/templates/grpc_service.go.tpl b/grpc/codegen/templates/grpc_service.go.tpl index 4dbff130b6..b6af05f61d 100644 --- a/grpc/codegen/templates/grpc_service.go.tpl +++ b/grpc/codegen/templates/grpc_service.go.tpl @@ -5,7 +5,7 @@ service {{ .Name }} { {{ if .Method.Description }}{{ .Method.Description | comment }}{{ end }} {{- $serverStream := or (eq .Method.StreamKind 3) (eq .Method.StreamKind 4) }} {{- $clientStream := or (eq .Method.StreamKind 2) (eq .Method.StreamKind 4) }} - rpc {{ .Method.VarName }} ({{ if $clientStream }}stream {{ end }}{{ .Request.Message.VarName }}) returns ({{ if $serverStream }}stream {{ end }}{{ .Response.Message.VarName }}){{ if .Method.Idempotent }} { + rpc {{ .ProtoMethodName }} ({{ if $clientStream }}stream {{ end }}{{ .Request.ProtoMessageName }}) returns ({{ if $serverStream }}stream {{ end }}{{ .Response.ProtoMessageName }}){{ if .Method.Idempotent }} { option idempotency_level = IDEMPOTENT; }{{ else }};{{ end }} {{- end }} diff --git a/grpc/codegen/templates/parse_endpoint.go.tpl b/grpc/codegen/templates/parse_endpoint.go.tpl index 54d923a3f6..f5776baf0c 100644 --- a/grpc/codegen/templates/parse_endpoint.go.tpl +++ b/grpc/codegen/templates/parse_endpoint.go.tpl @@ -1,35 +1,35 @@ // ParseEndpoint returns the endpoint and payload as specified on the command // line. -func ParseEndpoint( - cc *grpc.ClientConn, +func {{ .Declaration.Name }}( + {{ .Variables.Connection }} *grpc.ClientConn, {{- range .Commands }} {{- if .Interceptors }} - {{ .Interceptors.VarName }} {{ .Interceptors.PkgName }}.ClientInterceptors, + {{ .Interceptors.ParserVar }} {{ .Interceptors.PkgName }}.{{ .Interceptors.ClientInterceptorsDeclaration.Name }}, {{- end }} {{- end }} - opts ...grpc.CallOption, + {{ .Variables.Options }} ...grpc.CallOption, ) (goa.Endpoint, any, error) { {{ .FlagsCode }} var ( - data any - endpoint goa.Endpoint - err error + {{ .Variables.Data }} any + {{ .Variables.Endpoint }} goa.Endpoint + {{ .Variables.Error }} error ) { - switch svcn { + switch {{ .Variables.ServiceName }} { {{- range .Commands }} case "{{ .Name }}": - c := {{ .PkgName }}.NewClient(cc, opts...) - switch epn { + {{ $.Variables.Client }} := {{ .PkgName }}.{{ .ClientInit.Name }}({{ $.Variables.Connection }}, {{ $.Variables.Options }}...) + switch {{ $.Variables.MethodName }} { {{- $pkgName := .PkgName }} {{- range .Subcommands }} case "{{ .Name }}": - endpoint = c.{{ .MethodVarName }}() + {{ $.Variables.Endpoint }} = {{ $.Variables.Client }}.{{ .MethodVarName }}() {{- if .Interceptors }} - endpoint = {{ .Interceptors.PkgName }}.Wrap{{ .MethodVarName }}ClientEndpoint(endpoint, {{ .Interceptors.VarName }}) + {{ $.Variables.Endpoint }} = {{ .Interceptors.PkgName }}.{{ .Interceptors.ClientEndpointWrapperDeclaration.Name }}({{ $.Variables.Endpoint }}, {{ .Interceptors.ParserVar }}) {{- end }} {{- if .BuildFunction }} - data, err = {{ $pkgName}}.{{ .BuildFunction.Name }}({{ range .BuildFunction.ActualParams }}*{{ . }}Flag, {{ end }}) + {{ $.Variables.Data }}, {{ $.Variables.Error }} = {{ $pkgName}}.{{ .BuildFunction.Name }}({{ range .ActualPointerVars }}*{{ . }}, {{ end }}) {{- else if .Conversion }} {{ .Conversion }} {{- end }} @@ -38,9 +38,9 @@ func ParseEndpoint( {{- end }} } } - if err != nil { - return nil, nil, err + if {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} } - return endpoint, data, nil + return {{ .Variables.Endpoint }}, {{ .Variables.Data }}, nil } diff --git a/grpc/codegen/templates/partial/convert_string_to_type.go.tpl b/grpc/codegen/templates/partial/convert_string_to_type.go.tpl index bc7edae3e3..fa7a905562 100644 --- a/grpc/codegen/templates/partial/convert_string_to_type.go.tpl +++ b/grpc/codegen/templates/partial/convert_string_to_type.go.tpl @@ -79,6 +79,4 @@ err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .VarName }}, {{ .VarName}}Raw, "boolean")) } {{ .VarName }} = {{ if .Pointer }}&{{ end }}v -{{- else }} - // unsupported type {{ .Type.Name }} for var {{ .VarName }} {{- end }} diff --git a/grpc/codegen/templates/partial/convert_type_to_string.go.tpl b/grpc/codegen/templates/partial/convert_type_to_string.go.tpl deleted file mode 100644 index 8cefc6771e..0000000000 --- a/grpc/codegen/templates/partial/convert_type_to_string.go.tpl +++ /dev/null @@ -1,27 +0,0 @@ -{{- if eq .Type.Name "boolean" -}} - {{ .VarName }} := strconv.FormatBool({{ .Target }}) -{{- else if eq .Type.Name "int" -}} - {{ .VarName }} := strconv.Itoa({{ .Target }}) -{{- else if eq .Type.Name "int32" -}} - {{ .VarName }} := strconv.FormatInt(int64({{ .Target }}), 10) -{{- else if eq .Type.Name "int64" -}} - {{ .VarName }} := strconv.FormatInt({{ .Target }}, 10) -{{- else if eq .Type.Name "uint" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) -{{- else if eq .Type.Name "uint32" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) -{{- else if eq .Type.Name "uint64" -}} - {{ .VarName }} := strconv.FormatUint({{ .Target }}, 10) -{{- else if eq .Type.Name "float32" -}} - {{ .VarName }} := strconv.FormatFloat(float64({{ .Target }}), 'f', -1, 32) -{{- else if eq .Type.Name "float64" -}} - {{ .VarName }} := strconv.FormatFloat({{ .Target }}, 'f', -1, 64) -{{- else if eq .Type.Name "string" -}} - {{ .VarName }} := {{ .Target }} -{{- else if eq .Type.Name "bytes" -}} - {{ .VarName }} := string({{ .Target }}) -{{- else if eq .Type.Name "any" -}} - {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) -{{- else }} - // unsupported type {{ .Type.Name }} for field {{ .FieldName }} -{{- end }} diff --git a/grpc/codegen/templates/partial/slice_item_conversion.go.tpl b/grpc/codegen/templates/partial/slice_item_conversion.go.tpl index 1e07c691c4..77b186d578 100644 --- a/grpc/codegen/templates/partial/slice_item_conversion.go.tpl +++ b/grpc/codegen/templates/partial/slice_item_conversion.go.tpl @@ -58,6 +58,4 @@ {{ .VarName }}[i] = v {{- else if eq .Type.ElemType.Type.Name "any" }} {{ .VarName }}[i] = rv -{{- else }} - // unsupported slice type {{ .Type.ElemType.Type.Name }} for var {{ .VarName }} {{- end }} diff --git a/grpc/codegen/templates/partial/string_conversion.go.tpl b/grpc/codegen/templates/partial/string_conversion.go.tpl deleted file mode 100644 index 8cefc6771e..0000000000 --- a/grpc/codegen/templates/partial/string_conversion.go.tpl +++ /dev/null @@ -1,27 +0,0 @@ -{{- if eq .Type.Name "boolean" -}} - {{ .VarName }} := strconv.FormatBool({{ .Target }}) -{{- else if eq .Type.Name "int" -}} - {{ .VarName }} := strconv.Itoa({{ .Target }}) -{{- else if eq .Type.Name "int32" -}} - {{ .VarName }} := strconv.FormatInt(int64({{ .Target }}), 10) -{{- else if eq .Type.Name "int64" -}} - {{ .VarName }} := strconv.FormatInt({{ .Target }}, 10) -{{- else if eq .Type.Name "uint" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) -{{- else if eq .Type.Name "uint32" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) -{{- else if eq .Type.Name "uint64" -}} - {{ .VarName }} := strconv.FormatUint({{ .Target }}, 10) -{{- else if eq .Type.Name "float32" -}} - {{ .VarName }} := strconv.FormatFloat(float64({{ .Target }}), 'f', -1, 32) -{{- else if eq .Type.Name "float64" -}} - {{ .VarName }} := strconv.FormatFloat({{ .Target }}, 'f', -1, 64) -{{- else if eq .Type.Name "string" -}} - {{ .VarName }} := {{ .Target }} -{{- else if eq .Type.Name "bytes" -}} - {{ .VarName }} := string({{ .Target }}) -{{- else if eq .Type.Name "any" -}} - {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) -{{- else }} - // unsupported type {{ .Type.Name }} for field {{ .FieldName }} -{{- end }} diff --git a/grpc/codegen/templates/partial/type_conversion.go.tpl b/grpc/codegen/templates/partial/type_conversion.go.tpl index bc7edae3e3..fa7a905562 100644 --- a/grpc/codegen/templates/partial/type_conversion.go.tpl +++ b/grpc/codegen/templates/partial/type_conversion.go.tpl @@ -79,6 +79,4 @@ err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .VarName }}, {{ .VarName}}Raw, "boolean")) } {{ .VarName }} = {{ if .Pointer }}&{{ end }}v -{{- else }} - // unsupported type {{ .Type.Name }} for var {{ .VarName }} {{- end }} diff --git a/grpc/codegen/templates/partial/type_to_string_expression.go.tpl b/grpc/codegen/templates/partial/type_to_string_expression.go.tpl new file mode 100644 index 0000000000..c25f17139e --- /dev/null +++ b/grpc/codegen/templates/partial/type_to_string_expression.go.tpl @@ -0,0 +1,25 @@ +{{- if eq .Type.Name "boolean" -}} +strconv.FormatBool({{ .Target }}) +{{- else if eq .Type.Name "int" -}} +strconv.Itoa({{ .Target }}) +{{- else if eq .Type.Name "int32" -}} +strconv.FormatInt(int64({{ .Target }}), 10) +{{- else if eq .Type.Name "int64" -}} +strconv.FormatInt({{ .Target }}, 10) +{{- else if eq .Type.Name "uint" -}} +strconv.FormatUint(uint64({{ .Target }}), 10) +{{- else if eq .Type.Name "uint32" -}} +strconv.FormatUint(uint64({{ .Target }}), 10) +{{- else if eq .Type.Name "uint64" -}} +strconv.FormatUint({{ .Target }}, 10) +{{- else if eq .Type.Name "float32" -}} +strconv.FormatFloat(float64({{ .Target }}), 'f', -1, 32) +{{- else if eq .Type.Name "float64" -}} +strconv.FormatFloat({{ .Target }}, 'f', -1, 64) +{{- else if eq .Type.Name "string" -}} +{{ .Target }} +{{- else if eq .Type.Name "bytes" -}} +string({{ .Target }}) +{{- else if eq .Type.Name "any" -}} +fmt.Sprintf("%v", {{ .Target }}) +{{- end }} diff --git a/grpc/codegen/templates/remote_method_builder.go.tpl b/grpc/codegen/templates/remote_method_builder.go.tpl index c96d079b03..2c9b2891f4 100644 --- a/grpc/codegen/templates/remote_method_builder.go.tpl +++ b/grpc/codegen/templates/remote_method_builder.go.tpl @@ -1,25 +1,25 @@ -{{ printf "Build%sFunc builds the remote method to invoke for %q service %q endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Build{{ .Method.VarName }}Func(grpccli {{ .PkgName }}.{{ .ClientInterface }}, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { +{{ printf "%s builds the remote method to invoke for %q service %q endpoint." .ClientBuildDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ClientBuildDeclaration.Name }}(grpccli {{ .ClientProtobufPkgName }}.{{ .ClientInterface }}, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { return func(ctx context.Context, reqpb any, opts ...grpc.CallOption) (any, error) { for _, opt := range cliopts { opts = append(opts, opt) } {{- if .Request.StreamEnvelope }} - stream, err := grpccli.{{ .ClientMethodName }}(ctx, opts...) + stream, err := grpccli.{{ .GRPCMethodName }}(ctx, opts...) if err != nil { return nil, err } if reqpb != nil { - if err := stream.Send(reqpb.({{ .Request.Message.Ref }})); err != nil { + if err := stream.Send(reqpb.({{ .Request.ClientMessageRef }})); err != nil { return nil, err } } return stream, nil {{- else }} if reqpb != nil { - return grpccli.{{ .ClientMethodName }}(ctx{{ if not .Method.StreamingPayload }}, reqpb.({{ .Request.ClientConvert.TgtRef }}){{ end }}, opts...) + return grpccli.{{ .GRPCMethodName }}(ctx{{ if not .Method.StreamingPayload }}, reqpb.({{ .Request.ClientConvert.TgtRef }}){{ end }}, opts...) } - return grpccli.{{ .ClientMethodName }}(ctx{{ if not .Method.StreamingPayload }}, &{{ .Request.ClientConvert.TgtName }}{}{{ end }}, opts...) + return grpccli.{{ .GRPCMethodName }}(ctx{{ if not .Method.StreamingPayload }}, &{{ .Request.ClientConvert.TgtName }}{}{{ end }}, opts...) {{- end }} } } diff --git a/grpc/codegen/templates/request_decoder.go.tpl b/grpc/codegen/templates/request_decoder.go.tpl index 9e3bb95486..6da77e5e1a 100644 --- a/grpc/codegen/templates/request_decoder.go.tpl +++ b/grpc/codegen/templates/request_decoder.go.tpl @@ -1,14 +1,14 @@ -{{ printf "Decode%sRequest decodes requests sent to %q service %q endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Decode{{ .Method.VarName }}Request(ctx context.Context, v any, md metadata.MD) (any, error) { +{{ printf "%s decodes requests sent to %q service %q endpoint." .ServerDecodeDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ServerDecodeDeclaration.Name }}(ctx context.Context, v any, md metadata.MD) (any, error) { {{- if .Request.LegacyDecode }} if !goagrpc.UsesStreamEnvelope(ctx) { - return {{ .Request.LegacyDecode.FuncName }}(ctx, md) + return {{ .Request.LegacyDecode.FuncDeclaration.Name }}(ctx, md) } {{- end }} {{- template "partial_metadata_decode" .Request.Metadata }} {{- if .Request.PayloadMessage }} var ( - message {{ .Request.PayloadMessage.Ref }} + message {{ .Request.ServerPayloadMessageRef }} ok bool ) { @@ -16,37 +16,37 @@ func Decode{{ .Method.VarName }}Request(ctx context.Context, v any, md metadata. if v == nil { return nil, goa.MissingFieldError("initial_payload", "stream") } - var envelope {{ .Request.Message.Ref }} - if envelope, ok = v.({{ .Request.Message.Ref }}); !ok { - return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Request.Message.Ref }}", v) + var envelope {{ .Request.ServerMessageRef }} + if envelope, ok = v.({{ .Request.ServerMessageRef }}); !ok { + return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Request.ServerMessageRef }}", v) } switch body := envelope.{{ .Request.StreamEnvelope.FieldName }}.(type) { - case *{{ .Request.StreamEnvelope.InitialWrapperRef }}: + case *{{ .Request.StreamEnvelope.ServerInitialWrapperRef }}: if body.{{ .Request.StreamEnvelope.InitialFieldName }} == nil { return nil, goa.MissingFieldError("initial_payload", "stream") } message = body.{{ .Request.StreamEnvelope.InitialFieldName }} - case *{{ .Request.StreamEnvelope.StreamItemWrapperRef }}: + case *{{ .Request.StreamEnvelope.ServerStreamItemWrapperRef }}: return nil, goa.InvalidFieldTypeError("body", "stream_item", "initial_payload") default: return nil, goa.MissingFieldError("initial_payload", "stream") } {{- else }} - if message, ok = v.({{ .Request.PayloadMessage.Ref }}); !ok { - return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Request.PayloadMessage.Ref }}", v) + if message, ok = v.({{ .Request.ServerPayloadMessageRef }}); !ok { + return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Request.ServerPayloadMessageRef }}", v) } {{- end }} {{- if .Request.ServerConvert.Validation }} - if err {{ if .Request.Metadata }}={{ else }}:={{ end }} {{ .Request.ServerConvert.Validation.Name }}(message); err != nil { + if err {{ if .Request.Metadata }}={{ else }}:={{ end }} {{ .Request.ServerConvert.Validation.Declaration.Name }}(message); err != nil { return nil, err } {{- end }} } {{- end }} - var payload {{ .PayloadRef }} + var payload {{ .ServerPayloadRef }} { {{- if .Request.ServerConvert }} - payload = {{ .Request.ServerConvert.Init.Name }}({{ range .Request.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) + payload = {{ .Request.ServerConvert.Init.Declaration.Name }}({{ range .Request.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- else }} payload = {{ (index .Request.Metadata 0).VarName }} {{- end }} @@ -56,13 +56,13 @@ func Decode{{ .Method.VarName }}Request(ctx context.Context, v any, md metadata. } {{- if .Request.LegacyDecode }} -{{ printf "%s decodes requests sent to %q service %q endpoint by clients that speak the legacy stream protocol which carries the method payload in gRPC request metadata." .Request.LegacyDecode.FuncName .ServiceName .Method.Name | comment }} -func {{ .Request.LegacyDecode.FuncName }}(ctx context.Context, md metadata.MD) (any, error) { +{{ printf "%s decodes requests sent to %q service %q endpoint by clients that speak the legacy stream protocol which carries the method payload in gRPC request metadata." .Request.LegacyDecode.FuncDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .Request.LegacyDecode.FuncDeclaration.Name }}(ctx context.Context, md metadata.MD) (any, error) { {{- template "partial_metadata_decode" .Request.LegacyDecode.Metadata }} - var payload {{ .PayloadRef }} + var payload {{ .ServerPayloadRef }} { {{- if .Request.LegacyDecode.ServerConvert }} - payload = {{ .Request.LegacyDecode.ServerConvert.Init.Name }}({{ range .Request.LegacyDecode.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) + payload = {{ .Request.LegacyDecode.ServerConvert.Init.Declaration.Name }}({{ range .Request.LegacyDecode.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- else }} payload = {{ (index .Request.LegacyDecode.Metadata 0).VarName }} {{- end }} diff --git a/grpc/codegen/templates/request_encoder.go.tpl b/grpc/codegen/templates/request_encoder.go.tpl index 35ea5fa7d0..32bbfc74f5 100644 --- a/grpc/codegen/templates/request_encoder.go.tpl +++ b/grpc/codegen/templates/request_encoder.go.tpl @@ -1,41 +1,36 @@ -{{ printf "Encode%sRequest encodes requests sent to %s %s endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Encode{{ .Method.VarName }}Request(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.({{ .PayloadRef }}) +{{ printf "%s encodes requests sent to %s %s endpoint." .ClientEncodeDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ClientEncodeDeclaration.Name }}(ctx context.Context, v any, md *metadata.MD) (any, error) { + payload, ok := v.({{ .ClientPayloadRef }}) if !ok { - return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .PayloadRef }}", v) + return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .ClientPayloadRef }}", v) } {{- range .Request.Metadata }} + {{- if .Pointer }} + if payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} != nil { + {{- end }} + {{ .EncodeCode }} {{- if .StringSlice }} - for _, value := range payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} { + for _, value := range {{ .WireVarName }} { (*md).Append({{ printf "%q" .Name }}, value) } {{- else if .Slice }} - for _, value := range payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} { - {{ template "partial_convert_type_to_string" (typeConversionData .Type.ElemType.Type "valueStr" "value") }} + for _, value := range {{ .WireVarName }} { + valueStr := {{ template "partial_type_to_string_expression" (typeStringExpressionData .Type.ElemType.Type "value") }} (*md).Append({{ printf "%q" .Name }}, valueStr) } {{- else }} - {{- if .Pointer }} - if payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} != nil { - {{- end }} {{- if (and (eq .Name "Authorization") (isBearer $.MetadataSchemes)) }} - if !strings.Contains({{ if .Pointer }}*{{ end }}payload{{ if .FieldName }}.{{ .FieldName }}{{ end }}, " ") { - (*md).Append(ctx, {{ printf "%q" .Name }}, "Bearer "+{{ if .Pointer }}*{{ end }}payload{{ if .FieldName }}.{{ .FieldName }}{{ end }}) + if !strings.Contains({{ .WireVarName }}, " ") { + (*md).Append(ctx, {{ printf "%q" .Name }}, "Bearer "+{{ .WireVarName }}) } else { {{- end }} - (*md).Append({{ printf "%q" .Name }}, - {{- if eq .Type.Name "bytes" }} string( - {{- else if not (eq .Type.Name "string") }} fmt.Sprintf("%v", - {{- end }} - {{- if .Pointer }}*{{ end }}payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} - {{- if or (eq .Type.Name "bytes") (not (eq .Type.Name "string")) }}) - {{- end }}) + (*md).Append({{ printf "%q" .Name }}, {{ template "partial_type_to_string_expression" (typeStringExpressionData .Type .WireVarName) }}) {{- if (and (eq .Name "Authorization") (isBearer $.MetadataSchemes)) }} } {{- end }} - {{- if .Pointer }} - } - {{- end }} + {{- end }} + {{- if .Pointer }} + } {{- end }} {{- end }} {{- if .Request.StreamEnvelope }} @@ -43,14 +38,14 @@ func Encode{{ .Method.VarName }}Request(ctx context.Context, v any, md *metadata {{- end }} {{- if .Request.ClientConvert }} {{- if .Request.StreamEnvelope }} - message := {{ .Request.ClientConvert.Init.Name }}({{ range .Request.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) - return &{{ .PkgName }}.{{ .Request.Message.VarName }}{ - {{ .Request.StreamEnvelope.FieldName }}: &{{ .Request.StreamEnvelope.InitialWrapperRef }}{ + message := {{ .Request.ClientConvert.Init.Declaration.Name }}({{ range .Request.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + return &{{ .ClientProtobufPkgName }}.{{ .Request.Message.VarName }}{ + {{ .Request.StreamEnvelope.FieldName }}: &{{ .Request.StreamEnvelope.ClientInitialWrapperRef }}{ {{ .Request.StreamEnvelope.InitialFieldName }}: message, }, }, nil {{- else }} - return {{ .Request.ClientConvert.Init.Name }}({{ range .Request.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}), nil + return {{ .Request.ClientConvert.Init.Declaration.Name }}({{ range .Request.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}), nil {{- end }} {{- else }} return nil, nil diff --git a/grpc/codegen/templates/response_decoder.go.tpl b/grpc/codegen/templates/response_decoder.go.tpl index 9c01e62cb5..6241663aee 100644 --- a/grpc/codegen/templates/response_decoder.go.tpl +++ b/grpc/codegen/templates/response_decoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "Decode%sResponse decodes responses from the %s %s endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { +{{ printf "%s decodes responses from the %s %s endpoint." .ClientDecodeDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ClientDecodeDeclaration.Name }}(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { {{- if or .Response.Headers .Response.Trailers }} var ( {{- range .Response.Headers }} @@ -28,7 +28,7 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m return nil, err } {{- end }} -{{- if .ViewedResultRef }} +{{- if and .ViewedResultRef (not .Method.ViewedResult.ViewName) (not .ClientStream) }} var view string { if vals := hdr.Get("goa-view"); len(vals) > 0 { @@ -37,29 +37,39 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m } {{- end }} {{- if .ClientStream }} - return &{{ .ClientStream.VarName }}{ + return &{{ .ClientStream.Declaration.Name }}{ stream: v.({{ .ClientStream.Interface }}), - {{- if .ViewedResultRef }} + {{- if and .ViewedResultRef (not .Method.ViewedResult.ViewName) (not .ClientStream) }} view: view, {{- end }} }, nil {{- else }} - message, ok := v.({{ .Response.ClientConvert.SrcRef }}) + {{ if hasInitArg .Response.ClientConvert.Init.Args "message" }}message{{ else }}_{{ end }}, ok := v.({{ .Response.ClientConvert.SrcRef }}) if !ok { return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Response.ClientConvert.SrcRef }}", v) } {{- if and .Response.ClientConvert.Validation (not .ViewedResultRef) }} - if err {{ if or .Response.Headers .Response.Trailers }}={{ else }}:={{ end }} {{ .Response.ClientConvert.Validation.Name }}(message); err != nil { + if err {{ if or .Response.Headers .Response.Trailers }}={{ else }}:={{ end }} {{ .Response.ClientConvert.Validation.Declaration.Name }}(message); err != nil { return nil, err } {{- end }} - res := {{ .Response.ClientConvert.Init.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + {{- if gt (len .Response.ClientConverts) 1 }} + var res {{ .Response.ClientConvert.TgtRef }} + switch view { + {{- range .Response.ClientConverts }} + case {{ printf "%q" .View }}{{ if eq .View "default" }}, ""{{ end }}: + res = {{ .Convert.Init.Declaration.Name }}({{ range .Convert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} + } + {{- else }} + res := {{ .Response.ClientConvert.Init.Declaration.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} {{- if .ViewedResultRef }} - vres := {{ if not .Method.ViewedResult.IsCollection }}&{{ end }}{{ .Method.ViewedResult.FullName }}{Projected: res, View: view} + vres := {{ if not .Method.ViewedResult.IsCollection }}&{{ end }}{{ .Method.ViewedResult.FullName }}{Projected: res, View: {{ if .Method.ViewedResult.ViewName }}{{ printf "%q" .Method.ViewedResult.ViewName }}{{ else }}view{{ end }}} if err {{ if or .Response.Headers .Response.Trailers }}={{ else }}:={{ end }} {{ .Method.ViewedResult.ViewsPkg }}.Validate{{ .Method.Result }}(vres); err != nil { return nil, err } - return {{ .ServicePkgName }}.{{ .Method.ViewedResult.ResultInit.Name }}({{ range .Method.ViewedResult.ResultInit.Args}}{{ .Name }}, {{ end }}), nil + return {{ .ClientServicePkgName }}.{{ .Method.ViewedResult.ResultInit.Declaration.Name }}({{ range .Method.ViewedResult.ResultInit.Args}}{{ .Name }}, {{ end }}), nil {{- else }} return res, nil {{- end }} @@ -76,7 +86,7 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m } {{- else }} if vals := {{ .VarName }}.Get({{ printf "%q" .Metadata.Name }}); len(vals) > 0 { - {{ .Metadata.VarName }} = vals[0] + {{ .Metadata.VarName }} = {{ if .Metadata.Pointer }}&{{ end }}vals[0] } {{- end }} {{- else if .Metadata.StringSlice }} @@ -106,12 +116,12 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m if vals := {{ .VarName }}.Get({{ printf "%q" .Metadata.Name }}); len(vals) == 0 { err = goa.MergeErrors(err, goa.MissingFieldError({{ printf "%q" .Metadata.Name }}, "metadata")) } else { - {{ .Metadata.VarName }}Raw = vals[0] + {{ .Metadata.VarName }}Raw := vals[0] {{ template "partial_type_conversion" .Metadata }} } {{- else }} if vals := {{ .VarName }}.Get({{ printf "%q" .Metadata.Name }}); len(vals) > 0 { - {{ .Metadata.VarName }}Raw = vals[0] + {{ .Metadata.VarName }}Raw := vals[0] {{ template "partial_type_conversion" .Metadata }} } {{- end }} diff --git a/grpc/codegen/templates/response_encoder.go.tpl b/grpc/codegen/templates/response_encoder.go.tpl index 32099cc1ef..076bf94c60 100644 --- a/grpc/codegen/templates/response_encoder.go.tpl +++ b/grpc/codegen/templates/response_encoder.go.tpl @@ -1,19 +1,35 @@ -{{ printf "Encode%sResponse encodes responses from the %q service %q endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Encode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { +{{ printf "%s encodes responses from the %q service %q endpoint." .ServerEncodeDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ServerEncodeDeclaration.Name }}(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { {{- if .ViewedResultRef }} vres, ok := v.({{ .ViewedResultRef }}) if !ok { return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .ViewedResultRef }}", v) } result := vres.Projected - (*hdr).Append("goa-view", vres.View) -{{- else if .ResultRef }} - result, ok := v.({{ .ResultRef }}) +{{- else if .ServerResultRef }} + result, ok := v.({{ .ServerResultRef }}) if !ok { - return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .ResultRef }}", v) + return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .ServerResultRef }}", v) } {{- end }} - resp := {{ .Response.ServerConvert.Init.Name }}({{ range .Response.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) +{{- if gt (len .Response.ServerConverts) 1 }} + var resp {{ .Response.ServerConvert.TgtRef }} + switch vres.View { + {{- range .Response.ServerConverts }} + case {{ printf "%q" .View }}{{ if eq .View "default" }}, ""{{ end }}: + resp = {{ .Convert.Init.Declaration.Name }}({{ range .Convert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} + {{- if and .ViewedResultRef (not .Method.ViewedResult.ViewName) }} + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{ {{ range .Response.ServerConverts }}{{ printf "%q" .View }}, {{ end }} }) + {{- end }} + } +{{- else }} +resp := {{ .Response.ServerConvert.Init.Declaration.Name }}({{ range .Response.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) +{{- end }} +{{- if .ViewedResultRef }} + (*hdr).Append("goa-view", {{ if .Method.ViewedResult.ViewName }}{{ printf "%q" .Method.ViewedResult.ViewName }}{{ else }}vres.View{{ end }}) +{{- end }} {{- range .Response.Headers }} {{ template "metadata_encoder" (metadataEncodeDecodeData . "(*hdr)") }} {{- end }} @@ -24,26 +40,21 @@ func Encode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr * } {{- define "metadata_encoder" }} + {{- if .Metadata.Pointer }} + if result.{{ .Metadata.FieldName }} != nil { + {{- end }} + {{ .Metadata.EncodeCode }} {{- if .Metadata.StringSlice }} - {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, res.{{ .Metadata.FieldName }}...) + {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, {{ .Metadata.WireVarName }}...) {{- else if .Metadata.Slice }} - for _, value := range res.{{ .Metadata.FieldName }} { - {{ template "partial_convert_type_to_string" (typeConversionData .Metadata.Type.ElemType.Type "valueStr" "value") }} + for _, value := range {{ .Metadata.WireVarName }} { + valueStr := {{ template "partial_type_to_string_expression" (typeStringExpressionData .Metadata.Type.ElemType.Type "value") }} {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, valueStr) } {{- else }} - {{- if .Metadata.Pointer }} - if res.{{ .Metadata.FieldName }} != nil { - {{- end }} - {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, - {{- if eq .Metadata.Type.Name "bytes" }} string( - {{- else if not (eq .Metadata.TypeName "string") }} fmt.Sprintf("%v", - {{- end }} - {{- if .Metadata.Pointer }}*{{ end }}p.{{ .Metadata.FieldName }} - {{- if or (eq .Metadata.Type.Name "bytes") (not (eq .Metadata.TypeName "string")) }}) - {{- end }}) - {{- if .Metadata.Pointer }} - } - {{- end }} + {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, {{ template "partial_type_to_string_expression" (typeStringExpressionData .Metadata.Type .Metadata.WireVarName) }}) + {{- end }} + {{- if .Metadata.Pointer }} + } {{- end }} {{- end }} diff --git a/grpc/codegen/templates/server_grpc_init.go.tpl b/grpc/codegen/templates/server_grpc_init.go.tpl index 439bf7bf43..f40633514a 100644 --- a/grpc/codegen/templates/server_grpc_init.go.tpl +++ b/grpc/codegen/templates/server_grpc_init.go.tpl @@ -5,15 +5,15 @@ // responses. var ( {{- range .Services }} - {{ .Service.VarName }}Server *{{.Service.PkgName}}svr.Server + {{ .Service.VarName }}Server *{{ .ServerPkgName }}.{{ .ServerStructDeclaration.Name }} {{- end }} ) { {{- range .Services }} {{- if .Endpoints }} - {{ .Service.VarName }}Server = {{ .Service.PkgName }}svr.New({{ .Service.VarName }}Endpoints{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}({{ .Service.VarName }}Endpoints{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) {{- else }} - {{ .Service.VarName }}Server = {{ .Service.PkgName }}svr.New(nil{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}(nil{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) {{- end }} {{- end }} } diff --git a/grpc/codegen/templates/server_grpc_interface.go.tpl b/grpc/codegen/templates/server_grpc_interface.go.tpl index 77cb305553..76b7ca3ec2 100644 --- a/grpc/codegen/templates/server_grpc_interface.go.tpl +++ b/grpc/codegen/templates/server_grpc_interface.go.tpl @@ -1,8 +1,8 @@ -{{ printf "%s implements the %q method in %s.%s interface." .Method.VarName .Method.VarName .PkgName .ServerInterface | comment }} -func (s *{{ .ServerStruct }}) {{ .Method.VarName }}( +{{ printf "%s implements the %q method in %s.%s interface." .GRPCMethodName .GRPCMethodName .ServerProtobufPkgName .ServerInterface | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) {{ .GRPCMethodName }}( {{- if not .ServerStream }}ctx context.Context, {{ end }} - {{- if not .Method.StreamingPayload }}message {{ .Request.Message.Ref }}{{ if .ServerStream }}, {{ end }}{{ end }} - {{- if .ServerStream }}stream {{ .ServerStream.Interface }}{{ end }}) {{ if .ServerStream }}error{{ else if .Response.Message }}({{ .Response.Message.Ref }}, error{{ if .Response.Message }}){{ end }}{{ end }} { + {{- if not .Method.StreamingPayload }}message {{ .Request.ServerMessageRef }}{{ if .ServerStream }}, {{ end }}{{ end }} + {{- if .ServerStream }}stream {{ .ServerStream.Interface }}{{ end }}) {{ if .ServerStream }}error{{ else if .Response.Message }}({{ .Response.ServerMessageRef }}, error{{ if .Response.Message }}){{ end }}{{ end }} { {{- if .ServerStream }} ctx := stream.Context() {{- end }} @@ -40,10 +40,10 @@ func (s *{{ .ServerStruct }}) {{ .Method.VarName }}( {{if .PayloadRef }}p{{ else }}_{{ end }}, err := s.{{ .Method.VarName }}H.Decode(ctx, {{ if .Method.StreamingPayload }}nil{{ else }}message{{ end }}) {{- end }} {{- template "handle_error" . }} - ep := &{{ .ServicePkgName }}.{{ .Method.VarName }}EndpointInput{ - Stream: &{{ .ServerStream.VarName }}{stream: stream{{ if .Request.LegacyDecode }}, legacy: !envelope{{ end }}}, + ep := &{{ .ServerServicePkgName }}.{{ .Method.EndpointInputDeclaration.Name }}{ + Stream: &{{ .ServerStream.Declaration.Name }}{stream: stream{{ if .Request.LegacyDecode }}, legacy: !envelope{{ end }}}, {{- if .PayloadRef }} - Payload: p.({{ .PayloadRef }}), + Payload: p.({{ .ServerPayloadRef }}), {{- end }} } err = s.{{ .Method.VarName }}H.Handle(ctx, ep) @@ -66,7 +66,7 @@ func (s *{{ .ServerStruct }}) {{ .Method.VarName }}( var er {{ .Response.ServerConvert.SrcRef }} errors.As(err, &er) {{- end }} - return {{ if not $.ServerStream }}nil, {{ end }}goagrpc.NewStatusError({{ .Response.StatusCode }}, err, {{ if .Response.ServerConvert }}{{ .Response.ServerConvert.Init.Name }}({{ range .Response.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}){{ else }}goagrpc.NewErrorResponse(err){{ end }}) + return {{ if not $.ServerStream }}nil, {{ end }}goagrpc.NewStatusError({{ .Response.StatusCode }}, err, {{ if .Response.ServerConvert }}{{ .Response.ServerConvert.Init.Declaration.Name }}({{ range .Response.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}){{ else }}goagrpc.NewErrorResponse(err){{ end }}) {{- end }} } } diff --git a/grpc/codegen/templates/server_grpc_register.go.tpl b/grpc/codegen/templates/server_grpc_register.go.tpl index 5a0d4df0bd..e8e34823f3 100644 --- a/grpc/codegen/templates/server_grpc_register.go.tpl +++ b/grpc/codegen/templates/server_grpc_register.go.tpl @@ -17,14 +17,14 @@ // Register the servers. {{- range .Services }} - {{ .PkgName }}.Register{{ goify .Service.VarName true }}Server(srv, {{ .Service.VarName }}Server) + {{ .ServerProtobufPkgName }}.{{ .RegisterFunction }}(srv, {{ .Service.VarName }}Server) {{- end }} - for svc, info := range srv.GetServiceInfo() { - for _, m := range info.Methods { - log.Printf(ctx, "serving gRPC method %s", svc + "/" + m.Name) - } - } + {{- range .Services }} + {{- range .Endpoints }} + log.Printf(ctx, "serving gRPC method %s", {{ printf "%q" .FullMethodName }}) + {{- end }} + {{- end }} // Register the server reflection service on the server. // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. diff --git a/grpc/codegen/templates/server_grpc_start.go.tpl b/grpc/codegen/templates/server_grpc_start.go.tpl index c963aed74b..93c10af375 100644 --- a/grpc/codegen/templates/server_grpc_start.go.tpl +++ b/grpc/codegen/templates/server_grpc_start.go.tpl @@ -1,2 +1,2 @@ {{ comment "handleGRPCServer starts configures and starts a gRPC server on the given URL. It shuts down the server if any error is received in the error channel." }} -func handleGRPCServer(ctx context.Context, u *url.URL{{ range $.Services }}{{ if .Service.Methods }}, {{ .Service.VarName }}Endpoints *{{ .Service.PkgName }}.Endpoints{{ end }}{{ end }}, wg *sync.WaitGroup, errc chan error, dbg bool) { +func handleGRPCServer(ctx context.Context, u *url.URL{{ range $.Services }}{{ if .Service.Methods }}, {{ .Service.VarName }}Endpoints *{{ .Service.PkgName }}.{{ .Service.EndpointsDeclaration.Name }}{{ end }}{{ end }}, wg *sync.WaitGroup, errc chan error, dbg bool) { diff --git a/grpc/codegen/templates/server_init.go.tpl b/grpc/codegen/templates/server_init.go.tpl index 7b01402a56..2a7e0309b8 100644 --- a/grpc/codegen/templates/server_init.go.tpl +++ b/grpc/codegen/templates/server_init.go.tpl @@ -1,8 +1,8 @@ -{{ printf "%s instantiates the server struct with the %s service endpoints." .ServerInit .Service.Name | comment }} -func {{ .ServerInit }}(e *{{ .Service.PkgName }}.Endpoints{{ if .HasUnaryEndpoint }}, uh goagrpc.UnaryHandler{{ end }}{{ if .HasStreamingEndpoint }}, sh goagrpc.StreamHandler{{ end }}) *{{ .ServerStruct }} { - return &{{ .ServerStruct }}{ +{{ printf "%s instantiates the server struct with the %s service endpoints." .ServerInitDeclaration.Name .Service.Name | comment }} +func {{ .ServerInitDeclaration.Name }}(e *{{ .ServerServicePkgName }}.{{ .Service.EndpointsDeclaration.Name }}{{ if .HasUnaryEndpoint }}, uh goagrpc.UnaryHandler{{ end }}{{ if .HasStreamingEndpoint }}, sh goagrpc.StreamHandler{{ end }}) *{{ .ServerStructDeclaration.Name }} { + return &{{ .ServerStructDeclaration.Name }}{ {{- range .Endpoints }} - {{ .Method.VarName }}H: New{{ .Method.VarName }}Handler(e.{{ .Method.VarName }}{{ if .ServerStream }}, sh{{ else }}, uh{{ end }}), + {{ .Method.VarName }}H: {{ .ServerHandlerDeclaration.Name }}(e.{{ .Method.VarName }}{{ if .ServerStream }}, sh{{ else }}, uh{{ end }}), {{- end }} } } diff --git a/grpc/codegen/templates/server_struct_type.go.tpl b/grpc/codegen/templates/server_struct_type.go.tpl index 3aa54d829e..bd377e96d4 100644 --- a/grpc/codegen/templates/server_struct_type.go.tpl +++ b/grpc/codegen/templates/server_struct_type.go.tpl @@ -1,7 +1,7 @@ -{{ printf "%s implements the %s.%s interface." .ServerStruct .PkgName .ServerInterface | comment }} -type {{ .ServerStruct }} struct { +{{ printf "%s implements the %s.%s interface." .ServerStructDeclaration.Name .ServerProtobufPkgName .ServerInterface | comment }} +type {{ .ServerStructDeclaration.Name }} struct { {{- range .Endpoints }} {{ .Method.VarName }}H {{ if .ServerStream }}goagrpc.StreamHandler{{ else }}goagrpc.UnaryHandler{{ end }} {{- end }} - {{ .PkgName }}.Unimplemented{{ .ServerInterface }} + {{ .ServerProtobufPkgName }}.{{ .UnimplementedServer }} } diff --git a/grpc/codegen/templates/stream_close.go.tpl b/grpc/codegen/templates/stream_close.go.tpl index bb35413c28..6f7323f3f1 100644 --- a/grpc/codegen/templates/stream_close.go.tpl +++ b/grpc/codegen/templates/stream_close.go.tpl @@ -1,5 +1,5 @@ -func (s *{{ .VarName }}) Close() error { +func (s *{{ .Declaration.Name }}) Close() error { {{- if eq .Type "client" }} {{- if .Endpoint.Method.Result }} {{ comment "Close the send direction of the stream" }} diff --git a/grpc/codegen/templates/stream_recv.go.tpl b/grpc/codegen/templates/stream_recv.go.tpl index e97fda3301..c4ed4502c0 100644 --- a/grpc/codegen/templates/stream_recv.go.tpl +++ b/grpc/codegen/templates/stream_recv.go.tpl @@ -1,5 +1,5 @@ {{ comment .RecvDesc }} -func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { +func (s *{{ .Declaration.Name }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { var res {{ .RecvRef }} {{- if and (eq .Type "server") .Endpoint.Request.LegacyDecode }} if s.legacy { @@ -8,11 +8,11 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { return res, err } {{- if .RecvConvert.Validation }} - if err := {{ .RecvConvert.Validation.Name }}(v); err != nil { + if err := {{ .RecvConvert.Validation.Declaration.Name }}(v); err != nil { return res, err } {{- end }} - return {{ .RecvConvert.Init.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}), nil + return {{ .RecvConvert.Init.Declaration.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}), nil } {{- end }} {{- if and (eq .Type "server") .Endpoint.Request.StreamEnvelope }} @@ -28,11 +28,11 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { {{- if .Response.ClientConvert }} case {{ .Response.ClientConvert.SrcRef }}: {{- if .Response.ClientConvert.Validation }} - if err := {{ .Response.ClientConvert.Validation.Name }}(message); err != nil { + if err := {{ .Response.ClientConvert.Validation.Declaration.Name }}(message); err != nil { return res, err } {{- end }} - return res, {{ .Response.ClientConvert.Init.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + return res, {{ .Response.ClientConvert.Init.Declaration.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- end }} {{- end }} case *goapb.ErrorResponse: @@ -44,11 +44,25 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { return res, err {{- end }} } + {{- if and .Endpoint.Method.ViewedResult (eq .Type "client") (not .Endpoint.Method.ViewedResult.ViewName) }} + if !s.viewSet { + hdr, err := s.stream.Header() + if err != nil { + return res, err + } + views := hdr.Get("goa-view") + if len(views) == 0 { + return res, goa.MissingFieldError("goa-view", "metadata") + } + s.view = views[0] + s.viewSet = true + } + {{- end }} {{- if and (eq .Type "server") .Endpoint.Request.StreamEnvelope }} - body, ok := message.{{ .Endpoint.Request.StreamEnvelope.FieldName }}.(*{{ .Endpoint.Request.StreamEnvelope.StreamItemWrapperRef }}) + body, ok := message.{{ .Endpoint.Request.StreamEnvelope.FieldName }}.(*{{ .Endpoint.Request.StreamEnvelope.ServerStreamItemWrapperRef }}) if !ok { switch message.{{ .Endpoint.Request.StreamEnvelope.FieldName }}.(type) { - case *{{ .Endpoint.Request.StreamEnvelope.InitialWrapperRef }}: + case *{{ .Endpoint.Request.StreamEnvelope.ServerInitialWrapperRef }}: return res, goa.InvalidFieldTypeError("body", "initial_payload", "stream_item") default: return res, goa.MissingFieldError("stream_item", "stream") @@ -60,23 +74,33 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { v := body.{{ .Endpoint.Request.StreamEnvelope.StreamItemFieldName }} {{- end }} {{- if and .Endpoint.Method.ViewedResult (eq .Type "client") }} - proj := {{ .RecvConvert.Init.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}) + {{- if gt (len .RecvConverts) 1 }} + var proj {{ .RecvConvert.TgtRef }} + switch s.view { + {{- range .RecvConverts }} + case {{ printf "%q" .View }}{{ if eq .View "default" }}, ""{{ end }}: + proj = {{ .Convert.Init.Declaration.Name }}({{ range .Convert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} + } + {{- else }} + proj := {{ .RecvConvert.Init.Declaration.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} vres := {{ if not .Endpoint.Method.ViewedResult.IsCollection }}&{{ end }}{{ .Endpoint.Method.ViewedResult.FullName }}{Projected: proj, View: {{ if .Endpoint.Method.ViewedResult.ViewName }}"{{ .Endpoint.Method.ViewedResult.ViewName }}"{{ else }}s.view{{ end }} } if err := {{ .Endpoint.Method.ViewedResult.ViewsPkg }}.Validate{{ .Endpoint.Method.Result }}(vres); err != nil { return nil, err } - return {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.ResultInit.Name }}(vres), nil + return {{ .Endpoint.ClientServicePkgName }}.{{ .Endpoint.Method.ViewedResult.ResultInit.Declaration.Name }}(vres), nil {{- else }} {{- if .RecvConvert.Validation }} - if err = {{ .RecvConvert.Validation.Name }}(v); err != nil { + if err = {{ .RecvConvert.Validation.Declaration.Name }}(v); err != nil { return res, err } {{- end }} - return {{ .RecvConvert.Init.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}), nil + return {{ .RecvConvert.Init.Declaration.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}), nil {{- end }} } {{ comment .RecvWithContextDesc }} -func (s *{{ .VarName }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvRef }}, error) { +func (s *{{ .Declaration.Name }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvRef }}, error) { return s.{{ .RecvName }}() } diff --git a/grpc/codegen/templates/stream_send.go.tpl b/grpc/codegen/templates/stream_send.go.tpl index ef0146a0ed..4347ddaeea 100644 --- a/grpc/codegen/templates/stream_send.go.tpl +++ b/grpc/codegen/templates/stream_send.go.tpl @@ -1,16 +1,47 @@ {{ comment .SendDesc }} -func (s *{{ .VarName }}) {{ .SendName }}(res {{ .SendRef }}) error { +func (s *{{ .Declaration.Name }}) {{ .SendName }}(res {{ .SendRef }}) error { {{- if and .Endpoint.Method.ViewedResult (eq .Type "server") }} + {{- if not .Endpoint.Method.ViewedResult.ViewName }} + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + {{- end }} {{- if .Endpoint.Method.ViewedResult.ViewName }} - vres := {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Name }}(res, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) + vres := {{ .Endpoint.ServerServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(res, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) {{- else }} - vres := {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Name }}(res, s.view) + vres := {{ .Endpoint.ServerServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(res, view) {{- end }} {{- end }} - v := {{ .SendConvert.Init.Name }}({{ if and .Endpoint.Method.ViewedResult (eq .Type "server") }}vres.Projected{{ else }}res{{ end }}) + {{- if gt (len .SendConverts) 1 }} + var v {{ .SendConvert.TgtRef }} + switch view { + {{- range .SendConverts }} + case {{ printf "%q" .View }}{{ if eq .View "default" }}, ""{{ end }}: + v = {{ .Convert.Init.Declaration.Name }}(vres.Projected) + {{- end }} + {{- if and .Endpoint.Method.ViewedResult (eq .Type "server") (not .Endpoint.Method.ViewedResult.ViewName) }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .SendConverts }}{{ printf "%q" .View }}, {{ end }} }) + {{- end }} + } + {{- else }} + v := {{ .SendConvert.Init.Declaration.Name }}({{ if and .Endpoint.Method.ViewedResult (eq .Type "server") }}vres.Projected{{ else }}res{{ end }}) + {{- end }} + {{- if and .Endpoint.Method.ViewedResult (eq .Type "server") (not .Endpoint.Method.ViewedResult.ViewName) }} + if s.sentView == "" { + if err := s.stream.SetHeader(metadata.Pairs("goa-view", view)); err != nil { + return err + } + s.sentView = view + } + {{- end }} {{- if and (eq .Type "client") .Endpoint.Request.StreamEnvelope }} - return s.stream.{{ .SendName }}(&{{ .Endpoint.PkgName }}.{{ .Endpoint.Request.Message.VarName }}{ - {{ .Endpoint.Request.StreamEnvelope.FieldName }}: &{{ .Endpoint.Request.StreamEnvelope.StreamItemWrapperRef }}{ + return s.stream.{{ .SendName }}(&{{ .Endpoint.ClientProtobufPkgName }}.{{ .Endpoint.Request.Message.VarName }}{ + {{ .Endpoint.Request.StreamEnvelope.FieldName }}: &{{ .Endpoint.Request.StreamEnvelope.ClientStreamItemWrapperRef }}{ {{ .Endpoint.Request.StreamEnvelope.StreamItemFieldName }}: v, }, }) @@ -20,6 +51,6 @@ func (s *{{ .VarName }}) {{ .SendName }}(res {{ .SendRef }}) error { } {{ comment .SendWithContextDesc }} -func (s *{{ .VarName }}) {{ .SendWithContextName }}(ctx context.Context, res {{ .SendRef }}) error { +func (s *{{ .Declaration.Name }}) {{ .SendWithContextName }}(ctx context.Context, res {{ .SendRef }}) error { return s.{{ .SendName }}(res) } diff --git a/grpc/codegen/templates/stream_set_view.go.tpl b/grpc/codegen/templates/stream_set_view.go.tpl index 3ac250a764..97cef29e6c 100644 --- a/grpc/codegen/templates/stream_set_view.go.tpl +++ b/grpc/codegen/templates/stream_set_view.go.tpl @@ -1,4 +1,7 @@ {{ printf "SetView sets the view." | comment }} -func (s *{{ .VarName }}) SetView(view string) { +func (s *{{ .Declaration.Name }}) SetView(view string) { s.view = view + {{- if eq .Type "client" }} + s.viewSet = true + {{- end }} } diff --git a/grpc/codegen/templates/stream_struct_type.go.tpl b/grpc/codegen/templates/stream_struct_type.go.tpl index a9a72f9270..13e75ed252 100644 --- a/grpc/codegen/templates/stream_struct_type.go.tpl +++ b/grpc/codegen/templates/stream_struct_type.go.tpl @@ -1,12 +1,18 @@ -{{ printf "%s implements the %s interface." .VarName .ServiceInterface | comment }} -type {{ .VarName }} struct { +{{ printf "%s implements the %s interface." .Declaration.Name .ServiceInterface | comment }} +type {{ .Declaration.Name }} struct { stream {{ .Interface }} {{- if and (eq .Type "server") .Endpoint.Request.LegacyDecode }} // legacy indicates that the client speaks the legacy stream protocol // which sends raw stream item frames instead of typed envelopes. legacy bool {{- end }} -{{- if .Endpoint.Method.ViewedResult }} +{{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} view string + {{- if eq .Type "server" }} + {{ comment "sentView is the result view named in the response header. Later sends must use the same view." }} + sentView string + {{- else }} + viewSet bool + {{- end }} {{- end }} } diff --git a/grpc/codegen/templates/transform_helper.go.tpl b/grpc/codegen/templates/transform_helper.go.tpl index 5b2c37b65a..51132bddd9 100644 --- a/grpc/codegen/templates/transform_helper.go.tpl +++ b/grpc/codegen/templates/transform_helper.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s builds a value of type %s from a value of type %s." .Name .ResultTypeRef .ParamTypeRef | comment }} -func {{ .Name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { +{{ printf "%s builds a value of type %s from a value of type %s." .Declaration.Name .ResultTypeRef .ParamTypeRef | comment }} +func {{ .Declaration.Name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { {{ .Code }} return res } diff --git a/grpc/codegen/templates/type_init.go.tpl b/grpc/codegen/templates/type_init.go.tpl index befe07f307..e5f2752498 100644 --- a/grpc/codegen/templates/type_init.go.tpl +++ b/grpc/codegen/templates/type_init.go.tpl @@ -1,10 +1,12 @@ {{ comment .Description }} -func {{ .Name }}({{ range .Args }}{{ .Name }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{ range .Args }}{{ .Name }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{ .Code }} {{- if .ReturnIsStruct }} {{- range .Args }} - {{- if .FieldName }} - {{ $.ReturnVarName }}.{{ .FieldName }} = {{ if isAlias .FieldType }}{{ fullName .FieldType }}({{ end }}{{ .Name }}{{ if isAlias .FieldType }}){{ end }} + {{- if .InitCode }} + {{ .InitCode }} + {{- else if .FieldName }} + {{ $.ReturnVarName }}.{{ .FieldName }} = {{ .Name }} {{- end }} {{- end }} {{- end }} diff --git a/grpc/codegen/templates/validate.go.tpl b/grpc/codegen/templates/validate.go.tpl index 93fb841e5e..ec4a2d25ba 100644 --- a/grpc/codegen/templates/validate.go.tpl +++ b/grpc/codegen/templates/validate.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s runs the validations defined on %s." .Name .SrcName | comment }} -func {{ .Name }}({{ .ArgName }} {{ .SrcRef }}) (err error) { +{{ printf "%s runs the validations defined on %s." .Declaration.Name .SrcName | comment }} +func {{ .Declaration.Name }}({{ .ArgName }} {{ .SrcRef }}) (err error) { {{ .Def }} return } diff --git a/grpc/codegen/testdata/client-bidirectional-streaming.golden b/grpc/codegen/testdata/client-bidirectional-streaming.golden new file mode 100644 index 0000000000..c28ba6a08f --- /dev/null +++ b/grpc/codegen/testdata/client-bidirectional-streaming.golden @@ -0,0 +1,40 @@ +import ( + "context" + "errors" + "flag" + "fmt" + "io" + + cli "generated.local/gen/grpc/cli/test_api" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { + switch flag.Arg(0) { + case "service-bidirectional-streaming-rpc": + switch flag.Arg(1) { + case "method-bidirectional-streaming-rpc": + return errors.New("example client does not support streamed input for service \"ServiceBidirectionalStreamingRPC\" method \"MethodBidirectionalStreamingRPC\"") + } + } + conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() + _, _, err = cli.ParseEndpoint( + conn, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + panic("parsed gRPC command has no generated result writer") +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/grpc/codegen/testdata/client-client-streaming.golden b/grpc/codegen/testdata/client-client-streaming.golden new file mode 100644 index 0000000000..392997e0ab --- /dev/null +++ b/grpc/codegen/testdata/client-client-streaming.golden @@ -0,0 +1,40 @@ +import ( + "context" + "errors" + "flag" + "fmt" + "io" + + cli "generated.local/gen/grpc/cli/test_api" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { + switch flag.Arg(0) { + case "service-client-streaming-rpc": + switch flag.Arg(1) { + case "method-client-streaming-rpc": + return errors.New("example client does not support streamed input for service \"ServiceClientStreamingRPC\" method \"MethodClientStreamingRPC\"") + } + } + conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() + _, _, err = cli.ParseEndpoint( + conn, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + panic("parsed gRPC command has no generated result writer") +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/grpc/codegen/testdata/client-interceptors.golden b/grpc/codegen/testdata/client-interceptors.golden index a391d06b41..a10d66044e 100644 --- a/grpc/codegen/testdata/client-interceptors.golden +++ b/grpc/codegen/testdata/client-interceptors.golden @@ -1,28 +1,43 @@ import ( + "context" + "errors" + "flag" "fmt" - cli "grpc/cli/test" - "os" + "io" - "./interceptors" - goa "goa.design/goa/v3/pkg" + cli "generated.local/gen/grpc/cli/test" + interceptors "generated.local/interceptors" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } + defer func() { + err = errors.Join(err, conn.Close()) + }() serviceWithInterceptorsInterceptors := interceptors.NewServiceWithInterceptorsClientInterceptors() - return cli.ParseEndpoint( + endpoint, payload, err := cli.ParseEndpoint( conn, serviceWithInterceptorsInterceptors, ) -} + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } -func grpcUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "service-with-interceptors": + switch flag.Arg(1) { + case "method-a": + return writeEndpointResult(ctx, stdout, endpoint, payload) + case "method-b": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } func grpcUsageExamples() string { diff --git a/grpc/codegen/testdata/client-no-server-pkgpath.golden b/grpc/codegen/testdata/client-no-server-pkgpath.golden index d1033172ff..5b2939cf9f 100644 --- a/grpc/codegen/testdata/client-no-server-pkgpath.golden +++ b/grpc/codegen/testdata/client-no-server-pkgpath.golden @@ -1,19 +1,36 @@ import ( + "context" + "errors" + "flag" "fmt" + "io" cli "my/pkg/path/grpc/cli/test_api" - "os" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-no-server.golden b/grpc/codegen/testdata/client-no-server.golden index e9259418ae..51834398dd 100644 --- a/grpc/codegen/testdata/client-no-server.golden +++ b/grpc/codegen/testdata/client-no-server.golden @@ -1,19 +1,36 @@ import ( + "context" + "errors" + "flag" "fmt" - cli "grpc/cli/test_api" - "os" + "io" - goa "goa.design/goa/v3/pkg" + cli "generated.local/gen/grpc/cli/test_api" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-hosting-multiple-services-pkgpath.golden b/grpc/codegen/testdata/client-server-hosting-multiple-services-pkgpath.golden index 5324d085af..5d3eec4ec5 100644 --- a/grpc/codegen/testdata/client-server-hosting-multiple-services-pkgpath.golden +++ b/grpc/codegen/testdata/client-server-hosting-multiple-services-pkgpath.golden @@ -1,19 +1,41 @@ import ( + "context" + "errors" + "flag" "fmt" + "io" cli "my/pkg/path/grpc/cli/single_host" - "os" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "another-service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-hosting-multiple-services.golden b/grpc/codegen/testdata/client-server-hosting-multiple-services.golden index dcfe3180eb..bf204eb478 100644 --- a/grpc/codegen/testdata/client-server-hosting-multiple-services.golden +++ b/grpc/codegen/testdata/client-server-hosting-multiple-services.golden @@ -1,19 +1,41 @@ import ( + "context" + "errors" + "flag" "fmt" - cli "grpc/cli/single_host" - "os" + "io" - goa "goa.design/goa/v3/pkg" + cli "generated.local/gen/grpc/cli/single_host" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "another-service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-hosting-service-subset-pkgpath.golden b/grpc/codegen/testdata/client-server-hosting-service-subset-pkgpath.golden index 5324d085af..2e9a1f2266 100644 --- a/grpc/codegen/testdata/client-server-hosting-service-subset-pkgpath.golden +++ b/grpc/codegen/testdata/client-server-hosting-service-subset-pkgpath.golden @@ -1,19 +1,36 @@ import ( + "context" + "errors" + "flag" "fmt" + "io" cli "my/pkg/path/grpc/cli/single_host" - "os" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-hosting-service-subset.golden b/grpc/codegen/testdata/client-server-hosting-service-subset.golden index dcfe3180eb..85b41f0223 100644 --- a/grpc/codegen/testdata/client-server-hosting-service-subset.golden +++ b/grpc/codegen/testdata/client-server-hosting-service-subset.golden @@ -1,19 +1,36 @@ import ( + "context" + "errors" + "flag" "fmt" - cli "grpc/cli/single_host" - "os" + "io" - goa "goa.design/goa/v3/pkg" + cli "generated.local/gen/grpc/cli/single_host" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-streaming.golden b/grpc/codegen/testdata/client-server-streaming.golden new file mode 100644 index 0000000000..b9f7b82826 --- /dev/null +++ b/grpc/codegen/testdata/client-server-streaming.golden @@ -0,0 +1,46 @@ +import ( + "context" + "errors" + "flag" + "fmt" + "io" + + cli "generated.local/gen/grpc/cli/test_api" + serviceserverstreamingrpc "generated.local/gen/service_server_streaming_rpc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { + conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( + conn, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service-server-streaming-rpc": + switch flag.Arg(1) { + case "method-server-streaming-rpc": + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.(serviceserverstreamingrpc.MethodServerStreamingRPCClientStream) + return writeStreamResults(ctx, stdout, stream.RecvWithContext) + } + } + panic("parsed gRPC command has no generated result writer") +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/grpc/codegen/testdata/dsls.go b/grpc/codegen/testdata/dsls.go index a38858a387..46c3b9e659 100644 --- a/grpc/codegen/testdata/dsls.go +++ b/grpc/codegen/testdata/dsls.go @@ -1,3 +1,5 @@ +// This file defines gRPC DSL fixtures used to exercise message, metadata, +// streaming, validation, and generated package ownership behavior. package testdata import ( @@ -201,10 +203,7 @@ var ServerStreamingResultWithViewsDSL = func() { Attributes(func() { Field(1, "IntField", Int) Field(2, "DoubleField", Float64) - }) - View("default", func() { - Attribute("IntField") - Attribute("DoubleField") + Required("IntField", "DoubleField") }) View("tiny", func() { Attribute("IntField") @@ -243,6 +242,32 @@ var ServerStreamingResultCollectionWithExplicitViewDSL = func() { }) } +var ClientStreamingResultCollectionWithExplicitViewDSL = func() { + var RT = ResultType("application/vnd.client-streaming-result", func() { + TypeName("ResultType") + Attributes(func() { + Field(1, "IntField", Int) + Field(2, "DoubleField", Float64) + }) + View("default", func() { + Attribute("IntField") + Attribute("DoubleField") + }) + View("tiny", func() { + Attribute("IntField") + }) + }) + Service("ServiceClientStreamingResultTypeCollectionWithExplicitView", func() { + Method("MethodClientStreamingResultTypeCollectionWithExplicitView", func() { + StreamingPayload(String) + Result(CollectionOf(RT), func() { + View("tiny") + }) + GRPC(func() {}) + }) + }) +} + var ClientStreamingRPCDSL = func() { Service("ServiceClientStreamingRPC", func() { Method("MethodClientStreamingRPC", func() { @@ -264,6 +289,24 @@ var ClientStreamingRPCWithPayloadDSL = func() { }) } +var ClientStreamingRPCWithMetadataOnlyPayloadDSL = func() { + Service("ServiceClientStreamingRPCWithMetadataOnlyPayload", func() { + Method("MethodClientStreamingRPCWithMetadataOnlyPayload", func() { + Payload(func() { + Field(1, "token", String) + Required("token") + }) + StreamingPayload(String) + Result(String) + GRPC(func() { + Metadata(func() { + Attribute("token") + }) + }) + }) + }) +} + var ClientStreamingRPCWithPayloadLegacyCompatDSL = func() { Service("ServiceClientStreamingRPCWithPayloadLegacyCompat", func() { Method("MethodClientStreamingRPCWithPayloadLegacyCompat", func() { @@ -541,10 +584,7 @@ var MessageResultTypeWithViewsDSL = func() { Attributes(func() { Field(1, "IntField", Int) Field(2, "StringField", String) - }) - View("default", func() { - Attribute("IntField") - Attribute("StringField") + Required("IntField", "StringField") }) View("tiny", func() { Attribute("IntField") @@ -564,6 +604,7 @@ var MessageResultTypeWithExplicitViewDSL = func() { Attributes(func() { Field(1, "IntField", Int) Field(2, "StringField", String) + Required("IntField", "StringField") }) View("default", func() { Attribute("IntField") @@ -1021,6 +1062,23 @@ var PayloadWithValidationsDSL = func() { }) } +var PayloadWithMessageDSL = func() { + Service("PayloadWithMessage", func() { + Method("show", func() { + Payload(func() { + Field(1, "tenantID", String, func() { + Example("tenant") + }) + Field(2, "recordID", String, func() { + Example("record") + }) + Required("tenantID", "recordID") + }) + GRPC(func() {}) + }) + }) +} + var StructMetaTypeDSL = func() { Service("UsingMetaTypes", func() { Method("Method", func() { @@ -1137,6 +1195,29 @@ var CustomMessageNameDSL = func() { }) } +var DistinctCustomMessageNamesDSL = func() { + var First = Type("First", func() { + Meta("struct:name:proto", "Shared") + Field(1, "value", String) + }) + var Second = Type("Second", func() { + Meta("struct:name:proto", "Shared") + Field(1, "value", String) + }) + Service("DistinctCustomMessageNames", func() { + Method("UseFirst", func() { + Payload(First) + Result(First) + GRPC(func() {}) + }) + Method("UseSecond", func() { + Payload(Second) + Result(Second) + GRPC(func() {}) + }) + }) +} + var InterceptorsDSL = func() { var LogInterceptor = Interceptor("Log", func() { Description("Logs request and response details") @@ -1156,12 +1237,16 @@ var InterceptorsDSL = func() { ClientInterceptor(LogInterceptor) Method("MethodA", func() { ClientInterceptor(MetricsInterceptor) - Payload(String) + Payload(String, func() { + Example("hello") + }) Result(String) GRPC(func() {}) }) Method("MethodB", func() { - Payload(Int) + Payload(Int, func() { + Example(42) + }) Result(Int) GRPC(func() {}) }) diff --git a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden index 43aecefa91..be365f6906 100644 --- a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden +++ b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden @@ -6,12 +6,12 @@ package cli import ( - servicewithinterceptors "/service_with_interceptors" "flag" "fmt" - servicewithinterceptorsc "grpc/service_with_interceptors/client" "os" + servicewithinterceptorsc "generated.local/gen/grpc/service_with_interceptors/client" + servicewithinterceptors "generated.local/gen/service_with_interceptors" goa "goa.design/goa/v3/pkg" grpc "google.golang.org/grpc" ) @@ -27,7 +27,7 @@ func UsageCommands() []string { // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { - return os.Args[0] + " " + "service-with-interceptors method-a --message '{\n \"field\": \"Quidem molestiae possimus et vel vel perspiciatis.\"\n }'" + "\n" + + return os.Args[0] + " " + "service-with-interceptors method-a --message '{\n \"field\": \"hello\"\n }'" + "\n" + "" } @@ -161,7 +161,7 @@ func serviceWithInterceptorsMethodAUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-a --message '{\n \"field\": \"Quidem molestiae possimus et vel vel perspiciatis.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-a --message '{\n \"field\": \"hello\"\n }'") } func serviceWithInterceptorsMethodBUsage() { @@ -179,5 +179,5 @@ func serviceWithInterceptorsMethodBUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-b --message '{\n \"field\": 8804614586670373312\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-b --message '{\n \"field\": 42\n }'") } diff --git a/grpc/codegen/testdata/golden/client_cli_payload-with-message.go.golden b/grpc/codegen/testdata/golden/client_cli_payload-with-message.go.golden new file mode 100644 index 0000000000..03d0793c12 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_cli_payload-with-message.go.golden @@ -0,0 +1,35 @@ +// PayloadWithMessage gRPC client CLI support package +// +// Command: +// goa + +package client + +import ( + "fmt" + + payload_with_messagepb "generated.local/gen/grpc/payload_with_message/pb" + payloadwithmessage "generated.local/gen/payload_with_message" + "google.golang.org/protobuf/encoding/protojson" +) + +// BuildShowPayload builds the payload for the PayloadWithMessage show endpoint +// from CLI flags. +func BuildShowPayload(payloadWithMessageShowMessage string) (*payloadwithmessage.ShowPayload, error) { + var err error + var message payload_with_messagepb.ShowRequest + { + if payloadWithMessageShowMessage != "" { + err = protojson.Unmarshal([]byte(payloadWithMessageShowMessage), &message) + if err != nil { + return nil, fmt.Errorf("invalid JSON for message, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"record_id\": \"record\",\n \"tenant_id\": \"tenant\"\n }'") + } + } + } + v := &payloadwithmessage.ShowPayload{ + TenantID: message.TenantId, + RecordID: message.RecordId, + } + + return v, nil +} diff --git a/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden b/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden index 8f436a645a..405c7cb3af 100644 --- a/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden +++ b/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden @@ -7,10 +7,10 @@ package client import ( "fmt" - payloadwithvalidation "payload_with_validation" "strconv" "unicode/utf8" + payloadwithvalidation "generated.local/gen/payload_with_validation" goa "goa.design/goa/v3/pkg" ) @@ -28,11 +28,11 @@ func BuildMethodAPayload(payloadWithValidationMethodAMetadataInt string, payload if err != nil { return nil, fmt.Errorf("invalid value for metadataInt, must be INT") } - if *metadataInt < 0 { - err = goa.MergeErrors(err, goa.InvalidRangeError("MetadataInt", *metadataInt, 0, true)) + if val < 0 { + err = goa.MergeErrors(err, goa.InvalidRangeError("MetadataInt", val, 0, true)) } - if *metadataInt > 100 { - err = goa.MergeErrors(err, goa.InvalidRangeError("MetadataInt", *metadataInt, 100, false)) + if val > 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("MetadataInt", val, 100, false)) } if err != nil { return nil, err @@ -43,11 +43,11 @@ func BuildMethodAPayload(payloadWithValidationMethodAMetadataInt string, payload { if payloadWithValidationMethodAMetadataString != "" { metadataString = &payloadWithValidationMethodAMetadataString - if utf8.RuneCountInString(*metadataString) < 5 { - err = goa.MergeErrors(err, goa.InvalidLengthError("MetadataString", *metadataString, utf8.RuneCountInString(*metadataString), 5, true)) + if utf8.RuneCountInString(payloadWithValidationMethodAMetadataString) < 5 { + err = goa.MergeErrors(err, goa.InvalidLengthError("MetadataString", payloadWithValidationMethodAMetadataString, utf8.RuneCountInString(payloadWithValidationMethodAMetadataString), 5, true)) } - if utf8.RuneCountInString(*metadataString) > 10 { - err = goa.MergeErrors(err, goa.InvalidLengthError("MetadataString", *metadataString, utf8.RuneCountInString(*metadataString), 10, false)) + if utf8.RuneCountInString(payloadWithValidationMethodAMetadataString) > 10 { + err = goa.MergeErrors(err, goa.InvalidLengthError("MetadataString", payloadWithValidationMethodAMetadataString, utf8.RuneCountInString(payloadWithValidationMethodAMetadataString), 10, false)) } if err != nil { return nil, err diff --git a/grpc/codegen/testdata/golden/client_types_client-alias-validation.go.golden b/grpc/codegen/testdata/golden/client_types_client-alias-validation.go.golden index 1129af7b45..e98095127a 100644 --- a/grpc/codegen/testdata/golden/client_types_client-alias-validation.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-alias-validation.go.golden @@ -1,14 +1,14 @@ -// NewProtoMethodResultWithAliasValidationRequest builds the gRPC request type -// from the payload of the "MethodResultWithAliasValidation" endpoint of the -// "ServiceResultWithAliasValidation" service. +// NewProtoMethodResultWithAliasValidationRequest builds +// *service_result_with_alias_validationpb.MethodResultWithAliasValidationRequest +// from metadata values. func NewProtoMethodResultWithAliasValidationRequest() *service_result_with_alias_validationpb.MethodResultWithAliasValidationRequest { message := &service_result_with_alias_validationpb.MethodResultWithAliasValidationRequest{} return message } -// NewMethodResultWithAliasValidationResult builds the result type of the -// "MethodResultWithAliasValidation" endpoint of the -// "ServiceResultWithAliasValidation" service from the gRPC response type. +// NewMethodResultWithAliasValidationResult builds +// serviceresultwithaliasvalidation.UUID from +// *service_result_with_alias_validationpb.UUID. func NewMethodResultWithAliasValidationResult(message *service_result_with_alias_validationpb.UUID) serviceresultwithaliasvalidation.UUID { result := serviceresultwithaliasvalidation.UUID(message.Field) return result diff --git a/grpc/codegen/testdata/golden/client_types_client-bidirectional-streaming-same-type.go.golden b/grpc/codegen/testdata/golden/client_types_client-bidirectional-streaming-same-type.go.golden index 4bbda5f295..b58fed76f1 100644 --- a/grpc/codegen/testdata/golden/client_types_client-bidirectional-streaming-same-type.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-bidirectional-streaming-same-type.go.golden @@ -1,3 +1,6 @@ +// NewMethodBidirectionalStreamingRPCSameTypeResponseUserType builds +// *servicebidirectionalstreamingrpcsametype.UserType from +// *service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeResponse. func NewMethodBidirectionalStreamingRPCSameTypeResponseUserType(v *service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeResponse) *servicebidirectionalstreamingrpcsametype.UserType { result := &servicebidirectionalstreamingrpcsametype.UserType{ B: v.B, @@ -9,6 +12,10 @@ func NewMethodBidirectionalStreamingRPCSameTypeResponseUserType(v *service_bidir return result } +// NewProtoUserTypeMethodBidirectionalStreamingRPCSameTypeStreamingRequest +// builds +// *service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeStreamingRequest +// from *servicebidirectionalstreamingrpcsametype.UserType. func NewProtoUserTypeMethodBidirectionalStreamingRPCSameTypeStreamingRequest(spayload *servicebidirectionalstreamingrpcsametype.UserType) *service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeStreamingRequest { v := &service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeStreamingRequest{ B: spayload.B, diff --git a/grpc/codegen/testdata/golden/client_types_client-default-fields.go.golden b/grpc/codegen/testdata/golden/client_types_client-default-fields.go.golden index 07f660310c..7fe13d9a38 100644 --- a/grpc/codegen/testdata/golden/client_types_client-default-fields.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-default-fields.go.golden @@ -1,5 +1,5 @@ -// NewProtoMethodRequest builds the gRPC request type from the payload of the -// "Method" endpoint of the "DefaultFields" service. +// NewProtoMethodRequest builds *default_fieldspb.MethodRequest from +// *defaultfields.MethodPayload. func NewProtoMethodRequest(payload *defaultfields.MethodPayload) *default_fieldspb.MethodRequest { message := &default_fieldspb.MethodRequest{ Req: payload.Req, diff --git a/grpc/codegen/testdata/golden/client_types_client-payload-with-alias-type.go.golden b/grpc/codegen/testdata/golden/client_types_client-payload-with-alias-type.go.golden index f5b2947f9d..01c1cf58ff 100644 --- a/grpc/codegen/testdata/golden/client_types_client-payload-with-alias-type.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-payload-with-alias-type.go.golden @@ -1,6 +1,6 @@ -// NewProtoMethodMessageUserTypeWithAliasRequest builds the gRPC request type -// from the payload of the "MethodMessageUserTypeWithAlias" endpoint of the -// "ServiceMessageUserTypeWithAlias" service. +// NewProtoMethodMessageUserTypeWithAliasRequest builds +// *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest +// from *servicemessageusertypewithalias.PayloadAliasT. func NewProtoMethodMessageUserTypeWithAliasRequest(payload *servicemessageusertypewithalias.PayloadAliasT) *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest { message := &service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest{ IntAliasField: int32(payload.IntAliasField), @@ -12,9 +12,9 @@ func NewProtoMethodMessageUserTypeWithAliasRequest(payload *servicemessageuserty return message } -// NewMethodMessageUserTypeWithAliasResult builds the result type of the -// "MethodMessageUserTypeWithAlias" endpoint of the -// "ServiceMessageUserTypeWithAlias" service from the gRPC response type. +// NewMethodMessageUserTypeWithAliasResult builds +// *servicemessageusertypewithalias.PayloadAliasT from +// *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse. func NewMethodMessageUserTypeWithAliasResult(message *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse) *servicemessageusertypewithalias.PayloadAliasT { result := &servicemessageusertypewithalias.PayloadAliasT{ IntAliasField: servicemessageusertypewithalias.IntAlias(message.IntAliasField), diff --git a/grpc/codegen/testdata/golden/client_types_client-payload-with-duplicate-use.go.golden b/grpc/codegen/testdata/golden/client_types_client-payload-with-duplicate-use.go.golden index 45aa6d3796..7c03c13ca2 100644 --- a/grpc/codegen/testdata/golden/client_types_client-payload-with-duplicate-use.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-payload-with-duplicate-use.go.golden @@ -1,6 +1,5 @@ -// NewProtoDupePayload builds the gRPC request type from the payload of the -// "MethodPayloadDuplicateA" endpoint of the "ServicePayloadWithNestedTypes" -// service. +// NewProtoDupePayload builds *service_payload_with_nested_typespb.DupePayload +// from servicepayloadwithnestedtypes.DupePayload. func NewProtoDupePayload(payload servicepayloadwithnestedtypes.DupePayload) *service_payload_with_nested_typespb.DupePayload { message := &service_payload_with_nested_typespb.DupePayload{} message.Field = string(payload) diff --git a/grpc/codegen/testdata/golden/client_types_client-payload-with-nested-types.go.golden b/grpc/codegen/testdata/golden/client_types_client-payload-with-nested-types.go.golden index 2945ad9abf..a45530d532 100644 --- a/grpc/codegen/testdata/golden/client_types_client-payload-with-nested-types.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-payload-with-nested-types.go.golden @@ -1,32 +1,33 @@ -// NewProtoMethodPayloadWithNestedTypesRequest builds the gRPC request type -// from the payload of the "MethodPayloadWithNestedTypes" endpoint of the -// "ServicePayloadWithNestedTypes" service. +// NewProtoMethodPayloadWithNestedTypesRequest builds +// *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest +// from *servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload. func NewProtoMethodPayloadWithNestedTypesRequest(payload *servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload) *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest { message := &service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest{} if payload.AParams != nil { - message.AParams = svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams(payload.AParams) + message.AParams = transformAParamsToProtoAParams(payload.AParams) } if payload.BParams != nil { - message.BParams = svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams(payload.BParams) + message.BParams = transformBParamsToProtoBParams(payload.BParams) } return message } -// protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams -// builds a value of type *servicepayloadwithnestedtypes.AParams from a value -// of type *service_payload_with_nested_typespb.AParams. -func protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams(v *service_payload_with_nested_typespb.AParams) *servicepayloadwithnestedtypes.AParams { +// transformAParamsToProtoAParams builds a value of type +// *service_payload_with_nested_typespb.AParams from a value of type +// *servicepayloadwithnestedtypes.AParams. +func transformAParamsToProtoAParams(v *servicepayloadwithnestedtypes.AParams) *service_payload_with_nested_typespb.AParams { if v == nil { return nil } - res := &servicepayloadwithnestedtypes.AParams{} + res := &service_payload_with_nested_typespb.AParams{} if v.A != nil { - res.A = make(map[string][]string, len(v.A)) + res.A = make(map[string]*service_payload_with_nested_typespb.ArrayOfString, len(v.A)) for key, val := range v.A { tk := key - tv := make([]string, len(val.Field)) - for i, val := range val.Field { - tv[i] = val + tv := &service_payload_with_nested_typespb.ArrayOfString{} + tv.Field = make([]string, len(val)) + for i, val := range val { + tv.Field[i] = val } res.A[tk] = tv } @@ -35,14 +36,14 @@ func protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtyp return res } -// protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams -// builds a value of type *servicepayloadwithnestedtypes.BParams from a value -// of type *service_payload_with_nested_typespb.BParams. -func protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams(v *service_payload_with_nested_typespb.BParams) *servicepayloadwithnestedtypes.BParams { +// transformBParamsToProtoBParams builds a value of type +// *service_payload_with_nested_typespb.BParams from a value of type +// *servicepayloadwithnestedtypes.BParams. +func transformBParamsToProtoBParams(v *servicepayloadwithnestedtypes.BParams) *service_payload_with_nested_typespb.BParams { if v == nil { return nil } - res := &servicepayloadwithnestedtypes.BParams{} + res := &service_payload_with_nested_typespb.BParams{} if v.B != nil { res.B = make(map[string]string, len(v.B)) for key, val := range v.B { @@ -55,22 +56,21 @@ func protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtyp return res } -// svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams -// builds a value of type *service_payload_with_nested_typespb.AParams from a -// value of type *servicepayloadwithnestedtypes.AParams. -func svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams(v *servicepayloadwithnestedtypes.AParams) *service_payload_with_nested_typespb.AParams { +// transformProtoAParamsToAParams builds a value of type +// *servicepayloadwithnestedtypes.AParams from a value of type +// *service_payload_with_nested_typespb.AParams. +func transformProtoAParamsToAParams(v *service_payload_with_nested_typespb.AParams) *servicepayloadwithnestedtypes.AParams { if v == nil { return nil } - res := &service_payload_with_nested_typespb.AParams{} + res := &servicepayloadwithnestedtypes.AParams{} if v.A != nil { - res.A = make(map[string]*service_payload_with_nested_typespb.ArrayOfString, len(v.A)) + res.A = make(map[string][]string, len(v.A)) for key, val := range v.A { tk := key - tv := &service_payload_with_nested_typespb.ArrayOfString{} - tv.Field = make([]string, len(val)) - for i, val := range val { - tv.Field[i] = val + tv := make([]string, len(val.Field)) + for i, val := range val.Field { + tv[i] = val } res.A[tk] = tv } @@ -79,14 +79,14 @@ func svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAPa return res } -// svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams -// builds a value of type *service_payload_with_nested_typespb.BParams from a -// value of type *servicepayloadwithnestedtypes.BParams. -func svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams(v *servicepayloadwithnestedtypes.BParams) *service_payload_with_nested_typespb.BParams { +// transformProtoBParamsToBParams builds a value of type +// *servicepayloadwithnestedtypes.BParams from a value of type +// *service_payload_with_nested_typespb.BParams. +func transformProtoBParamsToBParams(v *service_payload_with_nested_typespb.BParams) *servicepayloadwithnestedtypes.BParams { if v == nil { return nil } - res := &service_payload_with_nested_typespb.BParams{} + res := &servicepayloadwithnestedtypes.BParams{} if v.B != nil { res.B = make(map[string]string, len(v.B)) for key, val := range v.B { diff --git a/grpc/codegen/testdata/golden/client_types_client-required-union-validation.go.golden b/grpc/codegen/testdata/golden/client_types_client-required-union-validation.go.golden new file mode 100644 index 0000000000..4057042a28 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_types_client-required-union-validation.go.golden @@ -0,0 +1,65 @@ +// ValidateExchangeResponse runs the validations defined on ExchangeResponse. +func ValidateExchangeResponse(message *union_validationpb.ExchangeResponse) (err error) { + if message.Choice == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("choice", "message")) + } + switch v := message.Choice.(type) { + case *union_validationpb.ExchangeResponse_Number: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("number", "message.choice")) + break + } + if int(v.Number) < 1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("message.choice.value", int(v.Number), 1, true)) + } + + case *union_validationpb.ExchangeResponse_Detail: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("detail", "message.choice")) + break + } + if v.Detail == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("detail", "message.choice")) + break + } + + case *union_validationpb.ExchangeResponse_Inactive: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("inactive", "message.choice")) + break + } + if v.Inactive == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("inactive", "message.choice")) + break + } + + case *union_validationpb.ExchangeResponse_Blob: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("blob", "message.choice")) + break + } + if v.Blob == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("blob", "message.choice")) + break + } + + case *union_validationpb.ExchangeResponse_Token: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("token", "message.choice")) + break + } + + case *union_validationpb.ExchangeResponse_Metadata: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("metadata", "message.choice")) + break + } + if v.Metadata == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("metadata", "message.choice")) + break + } + + } + + return +} diff --git a/grpc/codegen/testdata/golden/client_types_client-result-collection.go.golden b/grpc/codegen/testdata/golden/client_types_client-result-collection.go.golden index e0dfc5b766..f97f9d8104 100644 --- a/grpc/codegen/testdata/golden/client_types_client-result-collection.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-result-collection.go.golden @@ -1,49 +1,26 @@ -// NewProtoMethodResultWithCollectionRequest builds the gRPC request type from -// the payload of the "MethodResultWithCollection" endpoint of the -// "ServiceResultWithCollection" service. +// NewProtoMethodResultWithCollectionRequest builds +// *service_result_with_collectionpb.MethodResultWithCollectionRequest from +// metadata values. func NewProtoMethodResultWithCollectionRequest() *service_result_with_collectionpb.MethodResultWithCollectionRequest { message := &service_result_with_collectionpb.MethodResultWithCollectionRequest{} return message } -// NewMethodResultWithCollectionResult builds the result type of the -// "MethodResultWithCollection" endpoint of the "ServiceResultWithCollection" -// service from the gRPC response type. +// NewMethodResultWithCollectionResult builds +// *serviceresultwithcollection.MethodResultWithCollectionResult from +// *service_result_with_collectionpb.MethodResultWithCollectionResponse. func NewMethodResultWithCollectionResult(message *service_result_with_collectionpb.MethodResultWithCollectionResponse) *serviceresultwithcollection.MethodResultWithCollectionResult { result := &serviceresultwithcollection.MethodResultWithCollectionResult{} if message.Result != nil { - result.Result = protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT(message.Result) + result.Result = transformProtoResultTToResultT(message.Result) } return result } -// svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT -// builds a value of type *service_result_with_collectionpb.ResultT from a -// value of type *serviceresultwithcollection.ResultT. -func svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT(v *serviceresultwithcollection.ResultT) *service_result_with_collectionpb.ResultT { - if v == nil { - return nil - } - res := &service_result_with_collectionpb.ResultT{} - if v.CollectionField != nil { - res.CollectionField = &service_result_with_collectionpb.RTCollection{} - res.CollectionField.Field = make([]*service_result_with_collectionpb.RT, len(v.CollectionField)) - for i, val := range v.CollectionField { - res.CollectionField.Field[i] = &service_result_with_collectionpb.RT{} - if val.IntField != nil { - intField := int32(*val.IntField) - res.CollectionField.Field[i].IntField = &intField - } - } - } - - return res -} - -// protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT -// builds a value of type *serviceresultwithcollection.ResultT from a value of -// type *service_result_with_collectionpb.ResultT. -func protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT(v *service_result_with_collectionpb.ResultT) *serviceresultwithcollection.ResultT { +// transformProtoResultTToResultT builds a value of type +// *serviceresultwithcollection.ResultT from a value of type +// *service_result_with_collectionpb.ResultT. +func transformProtoResultTToResultT(v *service_result_with_collectionpb.ResultT) *serviceresultwithcollection.ResultT { if v == nil { return nil } diff --git a/grpc/codegen/testdata/golden/client_types_client-result-with-explicit-view.go.golden b/grpc/codegen/testdata/golden/client_types_client-result-with-explicit-view.go.golden new file mode 100644 index 0000000000..257dd37b24 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_types_client-result-with-explicit-view.go.golden @@ -0,0 +1,17 @@ +// NewProtoMethodMessageResultTypeWithExplicitViewRequest builds +// *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewRequest +// from metadata values. +func NewProtoMethodMessageResultTypeWithExplicitViewRequest() *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewRequest { + message := &service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewRequest{} + return message +} + +// NewMethodMessageResultTypeWithExplicitViewResult builds +// *servicemessageresulttypewithexplicitviewviews.RTView from +// *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse. +func NewMethodMessageResultTypeWithExplicitViewResult(message *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse) *servicemessageresulttypewithexplicitviewviews.RTView { + result := &servicemessageresulttypewithexplicitviewviews.RTView{} + intField := int(message.IntField) + result.IntField = &intField + return result +} diff --git a/grpc/codegen/testdata/golden/client_types_client-result-with-views.go.golden b/grpc/codegen/testdata/golden/client_types_client-result-with-views.go.golden new file mode 100644 index 0000000000..43067100d9 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_types_client-result-with-views.go.golden @@ -0,0 +1,29 @@ +// NewProtoMethodMessageResultTypeWithViewsRequest builds +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsRequest +// from metadata values. +func NewProtoMethodMessageResultTypeWithViewsRequest() *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsRequest { + message := &service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsRequest{} + return message +} + +// NewMethodMessageResultTypeWithViewsResult builds +// *servicemessageresulttypewithviewsviews.RTView from +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse. +func NewMethodMessageResultTypeWithViewsResult(message *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse) *servicemessageresulttypewithviewsviews.RTView { + result := &servicemessageresulttypewithviewsviews.RTView{ + StringField: &message.StringField, + } + intField := int(message.IntField) + result.IntField = &intField + return result +} + +// NewMethodMessageResultTypeWithViewsResultTiny builds +// *servicemessageresulttypewithviewsviews.RTView from +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse. +func NewMethodMessageResultTypeWithViewsResultTiny(message *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse) *servicemessageresulttypewithviewsviews.RTView { + result := &servicemessageresulttypewithviewsviews.RTView{} + intField := int(message.IntField) + result.IntField = &intField + return result +} diff --git a/grpc/codegen/testdata/golden/client_types_client-streaming-result-with-views.go.golden b/grpc/codegen/testdata/golden/client_types_client-streaming-result-with-views.go.golden new file mode 100644 index 0000000000..cbc2ed3426 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_types_client-streaming-result-with-views.go.golden @@ -0,0 +1,29 @@ +// NewProtoMethodServerStreamingUserTypeRPCRequest builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCRequest +// from metadata values. +func NewProtoMethodServerStreamingUserTypeRPCRequest() *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCRequest { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCRequest{} + return message +} + +// NewMethodServerStreamingUserTypeRPCResponseResultTypeView builds +// *serviceserverstreamingusertyperpcviews.ResultTypeView from +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse. +func NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse) *serviceserverstreamingusertyperpcviews.ResultTypeView { + vresult := &serviceserverstreamingusertyperpcviews.ResultTypeView{ + DoubleField: &v.DoubleField, + } + intField := int(v.IntField) + vresult.IntField = &intField + return vresult +} + +// NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny builds +// *serviceserverstreamingusertyperpcviews.ResultTypeView from +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse. +func NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny(v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse) *serviceserverstreamingusertyperpcviews.ResultTypeView { + vresult := &serviceserverstreamingusertyperpcviews.ResultTypeView{} + intField := int(v.IntField) + vresult.IntField = &intField + return vresult +} diff --git a/grpc/codegen/testdata/golden/client_types_client-struct-field-name-meta-type.go.golden b/grpc/codegen/testdata/golden/client_types_client-struct-field-name-meta-type.go.golden index 6614d43394..771ef5fdb1 100644 --- a/grpc/codegen/testdata/golden/client_types_client-struct-field-name-meta-type.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-struct-field-name-meta-type.go.golden @@ -1,5 +1,5 @@ -// NewProtoMethodRequest builds the gRPC request type from the payload of the -// "Method" endpoint of the "UsingMetaTypes" service. +// NewProtoMethodRequest builds *using_meta_typespb.MethodRequest from +// *usingmetatypes.MethodPayload. func NewProtoMethodRequest(payload *usingmetatypes.MethodPayload) *using_meta_typespb.MethodRequest { message := &using_meta_typespb.MethodRequest{ A: &payload.Foo, @@ -13,8 +13,8 @@ func NewProtoMethodRequest(payload *usingmetatypes.MethodPayload) *using_meta_ty return message } -// NewMethodResult builds the result type of the "Method" endpoint of the -// "UsingMetaTypes" service from the gRPC response type. +// NewMethodResult builds *usingmetatypes.MethodResult from +// *using_meta_typespb.MethodResponse. func NewMethodResult(message *using_meta_typespb.MethodResponse) *usingmetatypes.MethodResult { result := &usingmetatypes.MethodResult{} if message.A != nil { diff --git a/grpc/codegen/testdata/golden/client_types_client-struct-meta-type.go.golden b/grpc/codegen/testdata/golden/client_types_client-struct-meta-type.go.golden index 38bac435c2..5c557cb6eb 100644 --- a/grpc/codegen/testdata/golden/client_types_client-struct-meta-type.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-struct-meta-type.go.golden @@ -1,5 +1,5 @@ -// NewProtoMethodRequest builds the gRPC request type from the payload of the -// "Method" endpoint of the "UsingMetaTypes" service. +// NewProtoMethodRequest builds *using_meta_typespb.MethodRequest from +// *usingmetatypes.MethodPayload. func NewProtoMethodRequest(payload *usingmetatypes.MethodPayload) *using_meta_typespb.MethodRequest { message := &using_meta_typespb.MethodRequest{} a := int64(payload.A) @@ -19,8 +19,8 @@ func NewProtoMethodRequest(payload *usingmetatypes.MethodPayload) *using_meta_ty return message } -// NewMethodResult builds the result type of the "Method" endpoint of the -// "UsingMetaTypes" service from the gRPC response type. +// NewMethodResult builds *usingmetatypes.MethodResult from +// *using_meta_typespb.MethodResponse. func NewMethodResult(message *using_meta_typespb.MethodResponse) *usingmetatypes.MethodResult { result := &usingmetatypes.MethodResult{} if message.A != nil { diff --git a/grpc/codegen/testdata/golden/client_types_client-with-errors.go.golden b/grpc/codegen/testdata/golden/client_types_client-with-errors.go.golden index dc512d56b5..38b14555c1 100644 --- a/grpc/codegen/testdata/golden/client_types_client-with-errors.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-with-errors.go.golden @@ -1,23 +1,21 @@ -// NewProtoMethodUnaryRPCWithErrorsRequest builds the gRPC request type from -// the payload of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewProtoMethodUnaryRPCWithErrorsRequest builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest from string. func NewProtoMethodUnaryRPCWithErrorsRequest(payload string) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest{} message.Field = payload return message } -// NewMethodUnaryRPCWithErrorsResult builds the result type of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC response type. +// NewMethodUnaryRPCWithErrorsResult builds string from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse. func NewMethodUnaryRPCWithErrorsResult(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse) string { result := message.Field return result } -// NewMethodUnaryRPCWithErrorsInternalError builds the error type of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC error response type. +// NewMethodUnaryRPCWithErrorsInternalError builds +// *serviceunaryrpcwitherrors.AnotherError from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError. func NewMethodUnaryRPCWithErrorsInternalError(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError) *serviceunaryrpcwitherrors.AnotherError { er := &serviceunaryrpcwitherrors.AnotherError{ Name: message.Name, @@ -26,9 +24,9 @@ func NewMethodUnaryRPCWithErrorsInternalError(message *service_unary_rpc_with_er return er } -// NewMethodUnaryRPCWithErrorsBadRequestError builds the error type of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC error response type. +// NewMethodUnaryRPCWithErrorsBadRequestError builds +// *serviceunaryrpcwitherrors.AnotherError from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError. func NewMethodUnaryRPCWithErrorsBadRequestError(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError) *serviceunaryrpcwitherrors.AnotherError { er := &serviceunaryrpcwitherrors.AnotherError{ Name: message.Name, @@ -37,9 +35,9 @@ func NewMethodUnaryRPCWithErrorsBadRequestError(message *service_unary_rpc_with_ return er } -// NewMethodUnaryRPCWithErrorsCustomErrorError builds the error type of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC error response type. +// NewMethodUnaryRPCWithErrorsCustomErrorError builds +// *serviceunaryrpcwitherrors.ErrorType from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError. func NewMethodUnaryRPCWithErrorsCustomErrorError(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError) *serviceunaryrpcwitherrors.ErrorType { er := &serviceunaryrpcwitherrors.ErrorType{ A: message.A, diff --git a/grpc/codegen/testdata/golden/planned_name_collisions.go.golden b/grpc/codegen/testdata/golden/planned_name_collisions.go.golden new file mode 100644 index 0000000000..aa1a2c15ba --- /dev/null +++ b/grpc/codegen/testdata/golden/planned_name_collisions.go.golden @@ -0,0 +1,271 @@ +// BuildWatchFunc2 builds the remote method to invoke for "SavedTransport" +// service "Watch" endpoint. +func BuildWatchFunc2(grpccli saved_transportpb.SavedTransportClient, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { + return func(ctx context.Context, reqpb any, opts ...grpc.CallOption) (any, error) { + for _, opt := range cliopts { + opts = append(opts, opt) + } + stream, err := grpccli.Watch(ctx, opts...) + if err != nil { + return nil, err + } + if reqpb != nil { + if err := stream.Send(reqpb.(*saved_transportpb.WatchStreamingRequest)); err != nil { + return nil, err + } + } + return stream, nil + } +} + +// EncodeWatchRequest2 encodes requests sent to SavedTransport Watch endpoint. +func EncodeWatchRequest2(ctx context.Context, v any, md *metadata.MD) (any, error) { + payload, ok := v.(*savedtransport.SavedPayload) + if !ok { + return nil, goagrpc.ErrInvalidType("SavedTransport", "Watch", "*savedtransport.SavedPayload", v) + } + tokenWire := payload.Token + (*md).Append("authorization", tokenWire) + (*md).Append(goagrpc.StreamProtocolMetadataKey, goagrpc.StreamProtocolEnvelope) + message := NewProtoWatchRequest2(payload) + return &saved_transportpb.WatchStreamingRequest{ + Body: &saved_transportpb.WatchStreamingRequest_InitialPayload{ + InitialPayload: message, + }, + }, nil +} + +// DecodeWatchResponse2 decodes responses from the SavedTransport Watch +// endpoint. +func DecodeWatchResponse2(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { + var ( + count int + value string + err error + ) + { + + if vals := hdr.Get("x-count"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("x-count", "metadata")) + } else { + countRaw := vals[0] + + v, err2 := strconv.ParseInt(countRaw, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("count", countRaw, "integer")) + } + count = int(v) + } + + if vals := trlr.Get("x-value"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("x-value", "metadata")) + } else { + value = vals[0] + } + } + if err != nil { + return nil, err + } + return &WatchClientStream2{ + stream: v.(saved_transportpb.SavedTransport_WatchClient), + }, nil +} + +// Watch calls the "Watch" function in saved_transportpb.SavedTransportClient +// interface. +func (c *Client) Watch() goa.Endpoint { + return func(ctx context.Context, v any) (any, error) { + inv := goagrpc.NewInvoker( + BuildWatchFunc2(c.grpccli, c.opts...), + EncodeWatchRequest2, + DecodeWatchResponse2) + res, err := inv.Invoke(ctx, v) + if err != nil { + // Try to decode a Goa error response detail before falling back to Fault. + resp := goagrpc.DecodeError(err) + if eresp, ok := resp.(*goapb.ErrorResponse); ok { + return nil, goagrpc.NewServiceError(eresp) + } + return nil, goa.Fault("%s", err.Error()) + } + return res, nil + } +} + +// WatchClientStream2 implements the savedtransport.WatchClientStream interface. +type WatchClientStream2 struct { + stream saved_transportpb.SavedTransport_WatchClient +} + +// DecodeWatchRequest2 decodes requests sent to "SavedTransport" service +// "Watch" endpoint. +func DecodeWatchRequest2(ctx context.Context, v any, md metadata.MD) (any, error) { + var ( + token string + err error + ) + { + if vals := md.Get("authorization"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("authorization", "metadata")) + } else { + token = vals[0] + } + } + if err != nil { + return nil, err + } + var ( + message *saved_transportpb.WatchRequest + ok bool + ) + { + if v == nil { + return nil, goa.MissingFieldError("initial_payload", "stream") + } + var envelope *saved_transportpb.WatchStreamingRequest + if envelope, ok = v.(*saved_transportpb.WatchStreamingRequest); !ok { + return nil, goagrpc.ErrInvalidType("SavedTransport", "Watch", "*saved_transportpb.WatchStreamingRequest", v) + } + switch body := envelope.Body.(type) { + case *saved_transportpb.WatchStreamingRequest_InitialPayload: + if body.InitialPayload == nil { + return nil, goa.MissingFieldError("initial_payload", "stream") + } + message = body.InitialPayload + case *saved_transportpb.WatchStreamingRequest_StreamItem: + return nil, goa.InvalidFieldTypeError("body", "stream_item", "initial_payload") + default: + return nil, goa.MissingFieldError("initial_payload", "stream") + } + if err = ValidateWatchRequest2(message); err != nil { + return nil, err + } + } + var payload *savedtransport.SavedPayload + { + payload = NewWatchPayload(message, token) + } + return payload, nil +} + +// EncodeWatchResponse2 encodes responses from the "SavedTransport" service +// "Watch" endpoint. +func EncodeWatchResponse2(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + result, ok := v.(*savedtransport.SavedResult) + if !ok { + return nil, goagrpc.ErrInvalidType("SavedTransport", "Watch", "*savedtransport.SavedResult", v) + } + resp := NewProtoSavedResult() + + countWire := result.Count + (*hdr).Append("x-count", strconv.Itoa(countWire)) + + valueWire := result.Value + (*trlr).Append("x-value", valueWire) + return resp, nil +} + +// NewWatchHandler2 creates a gRPC handler which serves the "SavedTransport" +// service "Watch" endpoint. +func NewWatchHandler2(endpoint goa.Endpoint, h goagrpc.StreamHandler) goagrpc.StreamHandler { + if h == nil { + h = goagrpc.NewStreamHandler(endpoint, DecodeWatchRequest2) + } + return h +} + +// Watch implements the "Watch" method in +// saved_transportpb.SavedTransportServer interface. +func (s *Server) Watch(stream saved_transportpb.SavedTransport_WatchServer) error { + ctx := stream.Context() + ctx = context.WithValue(ctx, goa.MethodKey, "Watch") + ctx = context.WithValue(ctx, goa.ServiceKey, "SavedTransport") + var reqpb any + message, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + reqpb = nil + } else { + return goagrpc.EncodeError(err) + } + } else { + reqpb = message + } + p, err := s.WatchH.Decode(ctx, reqpb) + if err != nil { + return goagrpc.EncodeError(err) + } + ep := &savedtransport.WatchEndpointInput{ + Stream: &WatchServerStream2{stream: stream}, + Payload: p.(*savedtransport.SavedPayload), + } + err = s.WatchH.Handle(ctx, ep) + if err != nil { + return goagrpc.EncodeError(err) + } + return nil +} + +// WatchServerStream2 implements the savedtransport.WatchServerStream interface. +type WatchServerStream2 struct { + stream saved_transportpb.SavedTransport_WatchServer +} + +// NewProtoWatchRequest2 builds *saved_transportpb.WatchRequest from +// *savedtransport.SavedPayload. +func NewProtoWatchRequest2(payload *savedtransport.SavedPayload) *saved_transportpb.WatchRequest { + message := &saved_transportpb.WatchRequest{ + Value: payload.Value, + } + return message +} + +// NewWatchResponseSavedResult builds *savedtransport.SavedResult from metadata +// values. +func NewWatchResponseSavedResult() *savedtransport.SavedResult { + result := &savedtransport.SavedResult{} + return result +} + +// NewProtoSavedStreamWatchStreamItem builds *saved_transportpb.WatchStreamItem +// from *savedtransport.SavedStream. +func NewProtoSavedStreamWatchStreamItem(spayload *savedtransport.SavedStream) *saved_transportpb.WatchStreamItem { + v := &saved_transportpb.WatchStreamItem{ + Value: spayload.Value, + } + return v +} + +// NewWatchPayload builds *savedtransport.SavedPayload from +// *saved_transportpb.WatchRequest. +func NewWatchPayload(message *saved_transportpb.WatchRequest, token string) *savedtransport.SavedPayload { + v := &savedtransport.SavedPayload{ + Value: message.Value, + } + v.Token = token + return v +} + +// NewProtoSavedResult builds *saved_transportpb.WatchResponse from +// *savedtransport.SavedResult. +func NewProtoSavedResult() *saved_transportpb.WatchResponse { + message := &saved_transportpb.WatchResponse{} + return message +} + +// NewWatchStreamItemSavedStream builds *savedtransport.SavedStream from +// *saved_transportpb.WatchStreamItem. +func NewWatchStreamItemSavedStream(v *saved_transportpb.WatchStreamItem) *savedtransport.SavedStream { + spayload := &savedtransport.SavedStream{ + Value: v.Value, + } + return spayload +} + +// ValidateWatchRequest2 runs the validations defined on WatchRequest. +func ValidateWatchRequest2(message *saved_transportpb.WatchRequest) (err error) { + if utf8.RuneCountInString(message.Value) < 2 { + err = goa.MergeErrors(err, goa.InvalidLengthError("message.value", message.Value, utf8.RuneCountInString(message.Value), 2, true)) + } + return +} diff --git a/grpc/codegen/testdata/golden/proto_protofiles-distinct-custom-message-names.proto.golden b/grpc/codegen/testdata/golden/proto_protofiles-distinct-custom-message-names.proto.golden new file mode 100644 index 0000000000..214a75cdb0 --- /dev/null +++ b/grpc/codegen/testdata/golden/proto_protofiles-distinct-custom-message-names.proto.golden @@ -0,0 +1,22 @@ + +syntax = "proto3"; + +package distinct_custom_message_names; + +option go_package = "/distinct_custom_message_namespb"; + +// Service is the DistinctCustomMessageNames service interface. +service DistinctCustomMessageNames { + // UseFirst implements UseFirst. + rpc UseFirst (Shared) returns (Shared); + // UseSecond implements UseSecond. + rpc UseSecond (Shared2) returns (Shared2); +} + +message Shared { + optional string value = 1; +} + +message Shared2 { + optional string value = 1; +} diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_defaults-to-defaults.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_defaults-to-defaults.go.golden index f22dc74bba..f7e0a6ef69 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_defaults-to-defaults.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_defaults-to-defaults.go.golden @@ -17,8 +17,7 @@ func transform() { } } { - var zero string - if target.RawJson == zero { + if target.RawJson == nil { target.RawJson = json.RawMessage{0x66, 0x6f, 0x6f} } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_embedded-oneof-to-embedded-oneof.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_embedded-oneof-to-embedded-oneof.go.golden index f096a8fa5e..a6db5e3516 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_embedded-oneof-to-embedded-oneof.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_embedded-oneof-to-embedded-oneof.go.golden @@ -6,7 +6,7 @@ func transform() { switch string(source.EmbeddedOneOf.Kind()) { case "string": actual, _ := source.EmbeddedOneOf.AsString() - target.EmbeddedOneOf = &proto.EmbeddedOneOf_String_{String_: string(actual)} + target.EmbeddedOneOf = &proto.EmbeddedOneOf_String_2{String_2: string(actual)} case "integer": actual, _ := source.EmbeddedOneOf.AsInteger() target.EmbeddedOneOf = &proto.EmbeddedOneOf_Integer{Integer: int32(actual)} @@ -18,10 +18,10 @@ func transform() { target.EmbeddedOneOf = &proto.EmbeddedOneOf_Number{Number: int32(actual)} case "array": actual, _ := source.EmbeddedOneOf.AsArray() - target.EmbeddedOneOf = &proto.EmbeddedOneOf_Array{Array: svcProtoEmbeddedOneOfArrayToProtoEmbeddedOneOfArray(actual)} + target.EmbeddedOneOf = &proto.EmbeddedOneOf_Array{Array: svcProtoEmbeddedOneOfArrayToProtoEmbeddedOneOfArray2(actual)} case "map": actual, _ := source.EmbeddedOneOf.AsMap() - target.EmbeddedOneOf = &proto.EmbeddedOneOf_Map_{Map_: svcProtoEmbeddedOneOfMapToProtoEmbeddedOneOfMap(actual)} + target.EmbeddedOneOf = &proto.EmbeddedOneOf_Map_{Map_: svcProtoEmbeddedOneOfMapToProtoEmbeddedOneOfMap2(actual)} case "user_type": actual, _ := source.EmbeddedOneOf.AsUserType() target.EmbeddedOneOf = &proto.EmbeddedOneOf_UserType{UserType: svcProtoSimpleOneOfToProtoSimpleOneOf(actual)} diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_result-type-collection-to-result-type-collection.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_result-type-collection-to-result-type-collection.go.golden index 372d2c3c91..16ec026b91 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_result-type-collection-to-result-type-collection.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_result-type-collection-to-result-type-collection.go.golden @@ -1,7 +1,7 @@ func transform() { target := &proto.ResultTypeCollection{} if source.Collection != nil { - target.Collection = &proto.ResultTypeCollection{} + target.Collection = &proto.ResultTypeCollection2{} target.Collection.Field = make([]*proto.ResultType, len(source.Collection)) for i, val := range source.Collection { target.Collection.Field[i] = &proto.ResultType{} diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_defaults-to-defaults.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_defaults-to-defaults.go.golden index a77e32ff1f..f423522ef8 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_defaults-to-defaults.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_defaults-to-defaults.go.golden @@ -17,8 +17,7 @@ func transform() { } } { - var zero json.RawMessage - if target.RawJSON == zero { + if target.RawJSON == nil { target.RawJSON = json.RawMessage{0x66, 0x6f, 0x6f} } } @@ -29,8 +28,7 @@ func transform() { } } { - var zero []byte - if target.Bytes == zero { + if target.Bytes == nil { target.Bytes = []byte{0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72} } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_embedded-oneof-to-embedded-oneof.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_embedded-oneof-to-embedded-oneof.go.golden index 4e75f8833f..8b9b815437 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_embedded-oneof-to-embedded-oneof.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_embedded-oneof-to-embedded-oneof.go.golden @@ -4,10 +4,10 @@ func transform() { } if source.EmbeddedOneOf != nil { switch val := source.EmbeddedOneOf.(type) { - case *proto.EmbeddedOneOf_String_: + case *proto.EmbeddedOneOf_String_2: { u := target.EmbeddedOneOf - u.SetString(proto.EmbeddedOneOfString(val.String_)) + u.SetString(proto.EmbeddedOneOfString(val.String_2)) target.EmbeddedOneOf = u } case *proto.EmbeddedOneOf_Integer: @@ -31,13 +31,13 @@ func transform() { case *proto.EmbeddedOneOf_Array: { u := target.EmbeddedOneOf - u.SetArray(protobufProtoEmbeddedOneOfArrayToProtoEmbeddedOneOfArray(val.Array)) + u.SetArray(protobufProtoEmbeddedOneOfArray2ToProtoEmbeddedOneOfArray(val.Array)) target.EmbeddedOneOf = u } case *proto.EmbeddedOneOf_Map_: { u := target.EmbeddedOneOf - u.SetMap(protobufProtoEmbeddedOneOfMapToProtoEmbeddedOneOfMap(val.Map_)) + u.SetMap(protobufProtoEmbeddedOneOfMap2ToProtoEmbeddedOneOfMap(val.Map_)) target.EmbeddedOneOf = u } case *proto.EmbeddedOneOf_UserType: diff --git a/grpc/codegen/testdata/golden/released_fixed_view_collection_constructor.go.golden b/grpc/codegen/testdata/golden/released_fixed_view_collection_constructor.go.golden new file mode 100644 index 0000000000..6bdec9df76 --- /dev/null +++ b/grpc/codegen/testdata/golden/released_fixed_view_collection_constructor.go.golden @@ -0,0 +1,57 @@ +// NewProtoResultTypeCollection builds +// *service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection +// from +// serviceclientstreamingresulttypecollectionwithexplicitviewviews.ResultTypeCollectionView. +func NewProtoResultTypeCollection(result serviceclientstreamingresulttypecollectionwithexplicitviewviews.ResultTypeCollectionView) *service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection { + message := &service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection{} + message.Field = make([]*service_client_streaming_result_type_collection_with_explicit_viewpb.ResultType, len(result)) + for i, val := range result { + message.Field[i] = &service_client_streaming_result_type_collection_with_explicit_viewpb.ResultType{} + if val.IntField != nil { + intField := int32(*val.IntField) + message.Field[i].IntField = &intField + } + } + return message +} + +// NewMethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequestMethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequest +// builds string from +// *service_client_streaming_result_type_collection_with_explicit_viewpb.MethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequest. +func NewMethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequestMethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequest(v *service_client_streaming_result_type_collection_with_explicit_viewpb.MethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequest) string { + spayload := v.Field + return spayload +} + +// EncodeMethodClientStreamingResultTypeCollectionWithExplicitViewResponse +// encodes responses from the +// "ServiceClientStreamingResultTypeCollectionWithExplicitView" service +// "MethodClientStreamingResultTypeCollectionWithExplicitView" endpoint. +func EncodeMethodClientStreamingResultTypeCollectionWithExplicitViewResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(serviceclientstreamingresulttypecollectionwithexplicitviewviews.ResultTypeCollection) + if !ok { + return nil, goagrpc.ErrInvalidType("ServiceClientStreamingResultTypeCollectionWithExplicitView", "MethodClientStreamingResultTypeCollectionWithExplicitView", "serviceclientstreamingresulttypecollectionwithexplicitviewviews.ResultTypeCollection", v) + } + result := vres.Projected + resp := NewProtoResultTypeCollection(result) + (*hdr).Append("goa-view", "tiny") + return resp, nil +} + +// SendAndClose streams instances of +// "service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection" +// to the "MethodClientStreamingResultTypeCollectionWithExplicitView" endpoint +// gRPC stream. +func (s *MethodClientStreamingResultTypeCollectionWithExplicitViewServerStream) SendAndClose(res serviceclientstreamingresulttypecollectionwithexplicitview.ResultTypeCollection) error { + vres := serviceclientstreamingresulttypecollectionwithexplicitview.NewViewedResultTypeCollection(res, "tiny") + v := NewProtoResultTypeCollection(vres.Projected) + return s.stream.SendAndClose(v) +} + +// SendAndCloseWithContext streams instances of +// "service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection" +// to the "MethodClientStreamingResultTypeCollectionWithExplicitView" endpoint +// gRPC stream with context. +func (s *MethodClientStreamingResultTypeCollectionWithExplicitViewServerStream) SendAndCloseWithContext(ctx context.Context, res serviceclientstreamingresulttypecollectionwithexplicitview.ResultTypeCollection) error { + return s.SendAndClose(res) +} diff --git a/grpc/codegen/testdata/golden/released_streaming_response_constructors.go.golden b/grpc/codegen/testdata/golden/released_streaming_response_constructors.go.golden new file mode 100644 index 0000000000..851cf6027b --- /dev/null +++ b/grpc/codegen/testdata/golden/released_streaming_response_constructors.go.golden @@ -0,0 +1,79 @@ +// NewProtoMethodServerStreamingUserTypeRPCResponse builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse +// from *serviceserverstreamingusertyperpcviews.ResultTypeView. +func NewProtoMethodServerStreamingUserTypeRPCResponse(result *serviceserverstreamingusertyperpcviews.ResultTypeView) *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse{ + IntField: int32(*result.IntField), + DoubleField: *result.DoubleField, + } + return message +} + +// NewProtoMethodServerStreamingUserTypeRPCResponseTiny builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse +// from *serviceserverstreamingusertyperpcviews.ResultTypeView. +func NewProtoMethodServerStreamingUserTypeRPCResponseTiny(result *serviceserverstreamingusertyperpcviews.ResultTypeView) *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse{ + IntField: int32(*result.IntField), + } + return message +} + +// EncodeMethodServerStreamingUserTypeRPCResponse encodes responses from the +// "ServiceServerStreamingUserTypeRPC" service +// "MethodServerStreamingUserTypeRPC" endpoint. +func EncodeMethodServerStreamingUserTypeRPCResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(*serviceserverstreamingusertyperpcviews.ResultType) + if !ok { + return nil, goagrpc.ErrInvalidType("ServiceServerStreamingUserTypeRPC", "MethodServerStreamingUserTypeRPC", "*serviceserverstreamingusertyperpcviews.ResultType", v) + } + result := vres.Projected + var resp *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse + switch vres.View { + case "tiny": + resp = NewProtoMethodServerStreamingUserTypeRPCResponseTiny(result) + case "default", "": + resp = NewProtoMethodServerStreamingUserTypeRPCResponse(result) + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{"tiny", "default"}) + } + (*hdr).Append("goa-view", vres.View) + return resp, nil +} + +// Send streams instances of +// "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" +// to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream. +func (s *MethodServerStreamingUserTypeRPCServerStream) Send(res *serviceserverstreamingusertyperpc.ResultType) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + vres := serviceserverstreamingusertyperpc.NewViewedResultType(res, view) + var v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse + switch view { + case "tiny": + v = NewProtoMethodServerStreamingUserTypeRPCResponseTiny(vres.Projected) + case "default", "": + v = NewProtoMethodServerStreamingUserTypeRPCResponse(vres.Projected) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "default"}) + } + if s.sentView == "" { + if err := s.stream.SetHeader(metadata.Pairs("goa-view", view)); err != nil { + return err + } + s.sentView = view + } + return s.stream.Send(v) +} + +// SendWithContext streams instances of +// "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" +// to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream with context. +func (s *MethodServerStreamingUserTypeRPCServerStream) SendWithContext(ctx context.Context, res *serviceserverstreamingusertyperpc.ResultType) error { + return s.Send(res) +} diff --git a/grpc/codegen/testdata/golden/request_decoder_request-decoder-metadata-only-payload-with-streaming-payload.go.golden b/grpc/codegen/testdata/golden/request_decoder_request-decoder-metadata-only-payload-with-streaming-payload.go.golden new file mode 100644 index 0000000000..02a139a599 --- /dev/null +++ b/grpc/codegen/testdata/golden/request_decoder_request-decoder-metadata-only-payload-with-streaming-payload.go.golden @@ -0,0 +1,24 @@ +// DecodeMethodClientStreamingRPCWithMetadataOnlyPayloadRequest decodes +// requests sent to "ServiceClientStreamingRPCWithMetadataOnlyPayload" service +// "MethodClientStreamingRPCWithMetadataOnlyPayload" endpoint. +func DecodeMethodClientStreamingRPCWithMetadataOnlyPayloadRequest(ctx context.Context, v any, md metadata.MD) (any, error) { + var ( + token string + err error + ) + { + if vals := md.Get("token"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("token", "metadata")) + } else { + token = vals[0] + } + } + if err != nil { + return nil, err + } + var payload *serviceclientstreamingrpcwithmetadataonlypayload.MethodClientStreamingRPCWithMetadataOnlyPayloadPayload + { + payload = NewMethodClientStreamingRPCWithMetadataOnlyPayloadPayload(token) + } + return payload, nil +} diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden index 3111d6d787..7c8f78f74a 100644 --- a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden @@ -6,7 +6,8 @@ func EncodeMethodMessageWithMetadataRequest(ctx context.Context, v any, md *meta return nil, goagrpc.ErrInvalidType("ServiceMessageWithMetadata", "MethodMessageWithMetadata", "*servicemessagewithmetadata.RequestUT", v) } if payload.InMetadata != nil { - (*md).Append("Authorization", fmt.Sprintf("%v", *payload.InMetadata)) + inMetadataWire := *payload.InMetadata + (*md).Append("Authorization", strconv.Itoa(inMetadataWire)) } return NewProtoMethodMessageWithMetadataRequest(payload), nil } diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden index be0c374124..bc48fd95db 100644 --- a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden @@ -6,16 +6,20 @@ func EncodeMethodMessageWithSecurityRequest(ctx context.Context, v any, md *meta return nil, goagrpc.ErrInvalidType("ServiceMessageWithSecurity", "MethodMessageWithSecurity", "*servicemessagewithsecurity.RequestUT", v) } if payload.Token != nil { - (*md).Append("authorization", *payload.Token) + tokenWire := *payload.Token + (*md).Append("authorization", tokenWire) } if payload.Key != nil { - (*md).Append("authorization", *payload.Key) + keyWire := *payload.Key + (*md).Append("authorization", keyWire) } if payload.Username != nil { - (*md).Append("username", *payload.Username) + usernameWire := *payload.Username + (*md).Append("username", usernameWire) } if payload.Password != nil { - (*md).Append("password", *payload.Password) + passwordWire := *payload.Password + (*md).Append("password", passwordWire) } return NewProtoMethodMessageWithSecurityRequest(payload), nil } diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden index 006be77c31..444c7cdf3d 100644 --- a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden @@ -6,7 +6,8 @@ func EncodeMethodMessageWithValidateRequest(ctx context.Context, v any, md *meta return nil, goagrpc.ErrInvalidType("ServiceMessageWithValidate", "MethodMessageWithValidate", "*servicemessagewithvalidate.RequestUT", v) } if payload.InMetadata != nil { - (*md).Append("Authorization", fmt.Sprintf("%v", *payload.InMetadata)) + inMetadataWire := *payload.InMetadata + (*md).Append("Authorization", strconv.Itoa(inMetadataWire)) } return NewProtoMethodMessageWithValidateRequest(payload), nil } diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-bidirectional-streaming.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-bidirectional-streaming.go.golden index f469ab0e62..92eb83d19c 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-bidirectional-streaming.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-bidirectional-streaming.go.golden @@ -1,14 +1,7 @@ // DecodeMethodBidirectionalStreamingRPCResponse decodes responses from the // ServiceBidirectionalStreamingRPC MethodBidirectionalStreamingRPC endpoint. func DecodeMethodBidirectionalStreamingRPCResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { - var view string - { - if vals := hdr.Get("goa-view"); len(vals) > 0 { - view = vals[0] - } - } return &MethodBidirectionalStreamingRPCClientStream{ stream: v.(service_bidirectional_streaming_rpcpb.ServiceBidirectionalStreamingRPC_MethodBidirectionalStreamingRPCClient), - view: view, }, nil } diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-collection.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-collection.go.golden index 67e0bf115d..9e1d02139c 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-collection.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-collection.go.golden @@ -12,7 +12,13 @@ func DecodeMethodMessageUserTypeWithNestedUserTypesResponse(ctx context.Context, if !ok { return nil, goagrpc.ErrInvalidType("ServiceMessageUserTypeWithNestedUserTypes", "MethodMessageUserTypeWithNestedUserTypes", "*service_message_user_type_with_nested_user_typespb.RTCollection", v) } - res := NewMethodMessageUserTypeWithNestedUserTypesResult(message) + var res servicemessageusertypewithnestedusertypesviews.RTCollectionView + switch view { + case "default", "": + res = NewMethodMessageUserTypeWithNestedUserTypesResult(message) + case "tiny": + res = NewMethodMessageUserTypeWithNestedUserTypesResultTiny(message) + } vres := servicemessageusertypewithnestedusertypesviews.RTCollection{Projected: res, View: view} if err := servicemessageusertypewithnestedusertypesviews.ValidateRTCollection(vres); err != nil { return nil, err diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-explicit-view.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-explicit-view.go.golden index 6b8a15f20a..85b4e7eaa6 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-explicit-view.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-explicit-view.go.golden @@ -2,18 +2,12 @@ // the ServiceMessageResultTypeWithExplicitView // MethodMessageResultTypeWithExplicitView endpoint. func DecodeMethodMessageResultTypeWithExplicitViewResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { - var view string - { - if vals := hdr.Get("goa-view"); len(vals) > 0 { - view = vals[0] - } - } message, ok := v.(*service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse) if !ok { return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithExplicitView", "MethodMessageResultTypeWithExplicitView", "*service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse", v) } res := NewMethodMessageResultTypeWithExplicitViewResult(message) - vres := &servicemessageresulttypewithexplicitviewviews.RT{Projected: res, View: view} + vres := &servicemessageresulttypewithexplicitviewviews.RT{Projected: res, View: "tiny"} if err := servicemessageresulttypewithexplicitviewviews.ValidateRT(vres); err != nil { return nil, err } diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-metadata.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-metadata.go.golden index ec7d24797c..8b99c522ef 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-metadata.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-metadata.go.golden @@ -9,7 +9,7 @@ func DecodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, tr { if vals := hdr.Get("Location"); len(vals) > 0 { - inHeaderRaw = vals[0] + inHeaderRaw := vals[0] v, err2 := strconv.ParseInt(inHeaderRaw, 10, strconv.IntSize) if err2 != nil { @@ -20,7 +20,7 @@ func DecodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, tr } if vals := trlr.Get("InTrailer"); len(vals) > 0 { - inTrailerRaw = vals[0] + inTrailerRaw := vals[0] v, err2 := strconv.ParseBool(inTrailerRaw) if err2 != nil { diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-validate.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-validate.go.golden index f00c4be27a..e2d491dbe2 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-validate.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-validate.go.golden @@ -9,7 +9,7 @@ func DecodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, tr { if vals := hdr.Get("Location"); len(vals) > 0 { - inHeaderRaw = vals[0] + inHeaderRaw := vals[0] v, err2 := strconv.ParseInt(inHeaderRaw, 10, strconv.IntSize) if err2 != nil { @@ -25,7 +25,7 @@ func DecodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, tr } if vals := trlr.Get("InTrailer"); len(vals) > 0 { - inTrailerRaw = vals[0] + inTrailerRaw := vals[0] v, err2 := strconv.ParseBool(inTrailerRaw) if err2 != nil { diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-views.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-views.go.golden index 0f229f21ab..e6d4ee8986 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-views.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-views.go.golden @@ -11,7 +11,13 @@ func DecodeMethodMessageResultTypeWithViewsResponse(ctx context.Context, v any, if !ok { return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithViews", "MethodMessageResultTypeWithViews", "*service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse", v) } - res := NewMethodMessageResultTypeWithViewsResult(message) + var res *servicemessageresulttypewithviewsviews.RTView + switch view { + case "tiny": + res = NewMethodMessageResultTypeWithViewsResultTiny(message) + case "default", "": + res = NewMethodMessageResultTypeWithViewsResult(message) + } vres := &servicemessageresulttypewithviewsviews.RT{Projected: res, View: view} if err := servicemessageresulttypewithviewsviews.ValidateRT(vres); err != nil { return nil, err diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-server-streaming-result-with-views.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-server-streaming-result-with-views.go.golden index 68e963cdce..0025b57207 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-server-streaming-result-with-views.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-server-streaming-result-with-views.go.golden @@ -1,14 +1,7 @@ // DecodeMethodServerStreamingUserTypeRPCResponse decodes responses from the // ServiceServerStreamingUserTypeRPC MethodServerStreamingUserTypeRPC endpoint. func DecodeMethodServerStreamingUserTypeRPCResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { - var view string - { - if vals := hdr.Get("goa-view"); len(vals) > 0 { - view = vals[0] - } - } return &MethodServerStreamingUserTypeRPCClientStream{ stream: v.(service_server_streaming_user_type_rpcpb.ServiceServerStreamingUserTypeRPC_MethodServerStreamingUserTypeRPCClient), - view: view, }, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-collection.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-collection.go.golden index bb2fe25f47..333ac28f59 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-collection.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-collection.go.golden @@ -7,7 +7,15 @@ func EncodeMethodMessageUserTypeWithNestedUserTypesResponse(ctx context.Context, return nil, goagrpc.ErrInvalidType("ServiceMessageUserTypeWithNestedUserTypes", "MethodMessageUserTypeWithNestedUserTypes", "servicemessageusertypewithnestedusertypesviews.RTCollection", v) } result := vres.Projected + var resp *service_message_user_type_with_nested_user_typespb.RTCollection + switch vres.View { + case "default", "": + resp = NewProtoRTCollection(result) + case "tiny": + resp = NewProtoRTCollectionTiny(result) + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{"default", "tiny"}) + } (*hdr).Append("goa-view", vres.View) - resp := NewProtoRTCollection(result) return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-explicit-view.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-explicit-view.go.golden index 525201a0f1..b8aa816b08 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-explicit-view.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-explicit-view.go.golden @@ -7,7 +7,7 @@ func EncodeMethodMessageResultTypeWithExplicitViewResponse(ctx context.Context, return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithExplicitView", "MethodMessageResultTypeWithExplicitView", "*servicemessageresulttypewithexplicitviewviews.RT", v) } result := vres.Projected - (*hdr).Append("goa-view", vres.View) resp := NewProtoMethodMessageResultTypeWithExplicitViewResponse(result) + (*hdr).Append("goa-view", "tiny") return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden index 2b501ff366..febb194a0d 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden @@ -7,12 +7,14 @@ func EncodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, tr } resp := NewProtoMethodMessageWithMetadataResponse(result) - if res.InHeader != nil { - (*hdr).Append("Location", fmt.Sprintf("%v", *p.InHeader)) + if result.InHeader != nil { + inHeaderWire := *result.InHeader + (*hdr).Append("Location", strconv.Itoa(inHeaderWire)) } - if res.InTrailer != nil { - (*trlr).Append("InTrailer", fmt.Sprintf("%v", *p.InTrailer)) + if result.InTrailer != nil { + inTrailerWire := *result.InTrailer + (*trlr).Append("InTrailer", strconv.FormatBool(inTrailerWire)) } return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden index 3257742570..a4acf54b13 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden @@ -7,12 +7,14 @@ func EncodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, tr } resp := NewProtoMethodMessageWithValidateResponse(result) - if res.InHeader != nil { - (*hdr).Append("Location", fmt.Sprintf("%v", *p.InHeader)) + if result.InHeader != nil { + inHeaderWire := *result.InHeader + (*hdr).Append("Location", strconv.Itoa(inHeaderWire)) } - if res.InTrailer != nil { - (*trlr).Append("InTrailer", fmt.Sprintf("%v", *p.InTrailer)) + if result.InTrailer != nil { + inTrailerWire := *result.InTrailer + (*trlr).Append("InTrailer", strconv.FormatBool(inTrailerWire)) } return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-views.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-views.go.golden index 3699a16bf5..afd26b1453 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-views.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-views.go.golden @@ -7,7 +7,15 @@ func EncodeMethodMessageResultTypeWithViewsResponse(ctx context.Context, v any, return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithViews", "MethodMessageResultTypeWithViews", "*servicemessageresulttypewithviewsviews.RT", v) } result := vres.Projected + var resp *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse + switch vres.View { + case "tiny": + resp = NewProtoMethodMessageResultTypeWithViewsResponseTiny(result) + case "default", "": + resp = NewProtoMethodMessageResultTypeWithViewsResponse(result) + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{"tiny", "default"}) + } (*hdr).Append("goa-view", vres.View) - resp := NewProtoMethodMessageResultTypeWithViewsResponse(result) return resp, nil } diff --git a/grpc/codegen/testdata/golden/server_types_server-alias-validation.go.golden b/grpc/codegen/testdata/golden/server_types_server-alias-validation.go.golden index cb8b274a93..42eff96b6d 100644 --- a/grpc/codegen/testdata/golden/server_types_server-alias-validation.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-alias-validation.go.golden @@ -1,14 +1,12 @@ -// NewMethodElemValidationPayload builds the payload of the -// "MethodElemValidation" endpoint of the "ServiceElemValidation" service from -// the gRPC request type. +// NewMethodElemValidationPayload builds serviceelemvalidation.UUID from +// *service_elem_validationpb.UUID. func NewMethodElemValidationPayload(message *service_elem_validationpb.UUID) serviceelemvalidation.UUID { v := serviceelemvalidation.UUID(message.Field) return v } -// NewProtoMethodElemValidationResponse builds the gRPC response type from the -// result of the "MethodElemValidation" endpoint of the "ServiceElemValidation" -// service. +// NewProtoMethodElemValidationResponse builds +// *service_elem_validationpb.MethodElemValidationResponse from metadata values. func NewProtoMethodElemValidationResponse() *service_elem_validationpb.MethodElemValidationResponse { message := &service_elem_validationpb.MethodElemValidationResponse{} return message diff --git a/grpc/codegen/testdata/golden/server_types_server-default-fields.go.golden b/grpc/codegen/testdata/golden/server_types_server-default-fields.go.golden index 806756a5d4..f6728dec94 100644 --- a/grpc/codegen/testdata/golden/server_types_server-default-fields.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-default-fields.go.golden @@ -1,5 +1,5 @@ -// NewMethodPayload builds the payload of the "Method" endpoint of the -// "DefaultFields" service from the gRPC request type. +// NewMethodPayload builds *defaultfields.MethodPayload from +// *default_fieldspb.MethodRequest. func NewMethodPayload(message *default_fieldspb.MethodRequest) *defaultfields.MethodPayload { v := &defaultfields.MethodPayload{ Req: message.Req, @@ -54,8 +54,8 @@ func NewMethodPayload(message *default_fieldspb.MethodRequest) *defaultfields.Me return v } -// NewProtoMethodResponse builds the gRPC response type from the result of the -// "Method" endpoint of the "DefaultFields" service. +// NewProtoMethodResponse builds *default_fieldspb.MethodResponse from metadata +// values. func NewProtoMethodResponse() *default_fieldspb.MethodResponse { message := &default_fieldspb.MethodResponse{} return message diff --git a/grpc/codegen/testdata/golden/server_types_server-elem-validation.go.golden b/grpc/codegen/testdata/golden/server_types_server-elem-validation.go.golden index 46fa2111a8..f4c87aa3e9 100644 --- a/grpc/codegen/testdata/golden/server_types_server-elem-validation.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-elem-validation.go.golden @@ -1,6 +1,5 @@ -// NewMethodElemValidationPayload builds the payload of the -// "MethodElemValidation" endpoint of the "ServiceElemValidation" service from -// the gRPC request type. +// NewMethodElemValidationPayload builds *serviceelemvalidation.PayloadType +// from *service_elem_validationpb.MethodElemValidationRequest. func NewMethodElemValidationPayload(message *service_elem_validationpb.MethodElemValidationRequest) *serviceelemvalidation.PayloadType { v := &serviceelemvalidation.PayloadType{} if message.Foo != nil { @@ -17,9 +16,8 @@ func NewMethodElemValidationPayload(message *service_elem_validationpb.MethodEle return v } -// NewProtoMethodElemValidationResponse builds the gRPC response type from the -// result of the "MethodElemValidation" endpoint of the "ServiceElemValidation" -// service. +// NewProtoMethodElemValidationResponse builds +// *service_elem_validationpb.MethodElemValidationResponse from metadata values. func NewProtoMethodElemValidationResponse() *service_elem_validationpb.MethodElemValidationResponse { message := &service_elem_validationpb.MethodElemValidationResponse{} return message diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-alias-type.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-alias-type.go.golden index 565b291bff..05d5b8b491 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-alias-type.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-alias-type.go.golden @@ -1,6 +1,6 @@ -// NewMethodMessageUserTypeWithAliasPayload builds the payload of the -// "MethodMessageUserTypeWithAlias" endpoint of the -// "ServiceMessageUserTypeWithAlias" service from the gRPC request type. +// NewMethodMessageUserTypeWithAliasPayload builds +// *servicemessageusertypewithalias.PayloadAliasT from +// *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest. func NewMethodMessageUserTypeWithAliasPayload(message *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest) *servicemessageusertypewithalias.PayloadAliasT { v := &servicemessageusertypewithalias.PayloadAliasT{ IntAliasField: servicemessageusertypewithalias.IntAlias(message.IntAliasField), @@ -12,9 +12,9 @@ func NewMethodMessageUserTypeWithAliasPayload(message *service_message_user_type return v } -// NewProtoMethodMessageUserTypeWithAliasResponse builds the gRPC response type -// from the result of the "MethodMessageUserTypeWithAlias" endpoint of the -// "ServiceMessageUserTypeWithAlias" service. +// NewProtoMethodMessageUserTypeWithAliasResponse builds +// *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse +// from *servicemessageusertypewithalias.PayloadAliasT. func NewProtoMethodMessageUserTypeWithAliasResponse(result *servicemessageusertypewithalias.PayloadAliasT) *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse { message := &service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse{ IntAliasField: int32(result.IntAliasField), diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-custom-type-package.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-custom-type-package.go.golden index 2ca3f95d14..a6317b5f99 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-custom-type-package.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-custom-type-package.go.golden @@ -1,6 +1,5 @@ -// NewMethodPayloadWithCustomTypePackagePayload builds the payload of the -// "MethodPayloadWithCustomTypePackage" endpoint of the -// "ServicePayloadWithCustomTypePackage" service from the gRPC request type. +// NewMethodPayloadWithCustomTypePackagePayload builds *types.CustomType from +// *service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageRequest. func NewMethodPayloadWithCustomTypePackagePayload(message *service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageRequest) *types.CustomType { v := &types.CustomType{} if message.Field != nil { @@ -10,9 +9,9 @@ func NewMethodPayloadWithCustomTypePackagePayload(message *service_payload_with_ return v } -// NewProtoMethodPayloadWithCustomTypePackageResponse builds the gRPC response -// type from the result of the "MethodPayloadWithCustomTypePackage" endpoint of -// the "ServicePayloadWithCustomTypePackage" service. +// NewProtoMethodPayloadWithCustomTypePackageResponse builds +// *service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageResponse +// from *types.CustomType. func NewProtoMethodPayloadWithCustomTypePackageResponse(result *types.CustomType) *service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageResponse { message := &service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageResponse{} if result.Field != nil { diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-duplicate-use.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-duplicate-use.go.golden index 7fc3e8f5eb..2f49c12f8f 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-duplicate-use.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-duplicate-use.go.golden @@ -1,30 +1,21 @@ -// NewMethodPayloadDuplicateAPayload builds the payload of the -// "MethodPayloadDuplicateA" endpoint of the "ServicePayloadWithNestedTypes" -// service from the gRPC request type. -func NewMethodPayloadDuplicateAPayload(message *service_payload_with_nested_typespb.DupePayload) servicepayloadwithnestedtypes.DupePayload { +// NewDupePayload builds servicepayloadwithnestedtypes.DupePayload from +// *service_payload_with_nested_typespb.DupePayload. +func NewDupePayload(message *service_payload_with_nested_typespb.DupePayload) servicepayloadwithnestedtypes.DupePayload { v := servicepayloadwithnestedtypes.DupePayload(message.Field) return v } -// NewProtoMethodPayloadDuplicateAResponse builds the gRPC response type from -// the result of the "MethodPayloadDuplicateA" endpoint of the -// "ServicePayloadWithNestedTypes" service. +// NewProtoMethodPayloadDuplicateAResponse builds +// *service_payload_with_nested_typespb.MethodPayloadDuplicateAResponse from +// metadata values. func NewProtoMethodPayloadDuplicateAResponse() *service_payload_with_nested_typespb.MethodPayloadDuplicateAResponse { message := &service_payload_with_nested_typespb.MethodPayloadDuplicateAResponse{} return message } -// NewMethodPayloadDuplicateBPayload builds the payload of the -// "MethodPayloadDuplicateB" endpoint of the "ServicePayloadWithNestedTypes" -// service from the gRPC request type. -func NewMethodPayloadDuplicateBPayload(message *service_payload_with_nested_typespb.DupePayload) servicepayloadwithnestedtypes.DupePayload { - v := servicepayloadwithnestedtypes.DupePayload(message.Field) - return v -} - -// NewProtoMethodPayloadDuplicateBResponse builds the gRPC response type from -// the result of the "MethodPayloadDuplicateB" endpoint of the -// "ServicePayloadWithNestedTypes" service. +// NewProtoMethodPayloadDuplicateBResponse builds +// *service_payload_with_nested_typespb.MethodPayloadDuplicateBResponse from +// metadata values. func NewProtoMethodPayloadDuplicateBResponse() *service_payload_with_nested_typespb.MethodPayloadDuplicateBResponse { message := &service_payload_with_nested_typespb.MethodPayloadDuplicateBResponse{} return message diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-mixed-attributes.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-mixed-attributes.go.golden index 2c4e2f962e..8f2e5d9989 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-mixed-attributes.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-mixed-attributes.go.golden @@ -1,5 +1,5 @@ -// NewUnaryMethodPayload builds the payload of the "UnaryMethod" endpoint of -// the "ServicePayloadWithMixedAttributes" service from the gRPC request type. +// NewUnaryMethodPayload builds *servicepayloadwithmixedattributes.APayload +// from *service_payload_with_mixed_attributespb.UnaryMethodRequest. func NewUnaryMethodPayload(message *service_payload_with_mixed_attributespb.UnaryMethodRequest) *servicepayloadwithmixedattributes.APayload { v := &servicepayloadwithmixedattributes.APayload{ Required: int(message.Required), @@ -18,22 +18,25 @@ func NewUnaryMethodPayload(message *service_payload_with_mixed_attributespb.Unar return v } -// NewProtoUnaryMethodResponse builds the gRPC response type from the result of -// the "UnaryMethod" endpoint of the "ServicePayloadWithMixedAttributes" -// service. +// NewProtoUnaryMethodResponse builds +// *service_payload_with_mixed_attributespb.UnaryMethodResponse from metadata +// values. func NewProtoUnaryMethodResponse() *service_payload_with_mixed_attributespb.UnaryMethodResponse { message := &service_payload_with_mixed_attributespb.UnaryMethodResponse{} return message } -// NewProtoStreamingMethodResponse builds the gRPC response type from the -// result of the "StreamingMethod" endpoint of the -// "ServicePayloadWithMixedAttributes" service. +// NewProtoStreamingMethodResponse builds +// *service_payload_with_mixed_attributespb.StreamingMethodResponse from +// metadata values. func NewProtoStreamingMethodResponse() *service_payload_with_mixed_attributespb.StreamingMethodResponse { message := &service_payload_with_mixed_attributespb.StreamingMethodResponse{} return message } +// NewStreamingMethodStreamingRequestAPayload builds +// *servicepayloadwithmixedattributes.APayload from +// *service_payload_with_mixed_attributespb.StreamingMethodStreamingRequest. func NewStreamingMethodStreamingRequestAPayload(v *service_payload_with_mixed_attributespb.StreamingMethodStreamingRequest) *servicepayloadwithmixedattributes.APayload { spayload := &servicepayloadwithmixedattributes.APayload{ Required: int(v.Required), diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-nested-types.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-nested-types.go.golden index 468b18e174..56c0bd9981 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-nested-types.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-nested-types.go.golden @@ -1,20 +1,20 @@ -// NewMethodPayloadWithNestedTypesPayload builds the payload of the -// "MethodPayloadWithNestedTypes" endpoint of the -// "ServicePayloadWithNestedTypes" service from the gRPC request type. +// NewMethodPayloadWithNestedTypesPayload builds +// *servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload from +// *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest. func NewMethodPayloadWithNestedTypesPayload(message *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest) *servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload { v := &servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload{} if message.AParams != nil { - v.AParams = protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams(message.AParams) + v.AParams = transformProtoAParamsToAParams(message.AParams) } if message.BParams != nil { - v.BParams = protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams(message.BParams) + v.BParams = transformProtoBParamsToBParams(message.BParams) } return v } -// NewProtoMethodPayloadWithNestedTypesResponse builds the gRPC response type -// from the result of the "MethodPayloadWithNestedTypes" endpoint of the -// "ServicePayloadWithNestedTypes" service. +// NewProtoMethodPayloadWithNestedTypesResponse builds +// *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesResponse +// from metadata values. func NewProtoMethodPayloadWithNestedTypesResponse() *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesResponse { message := &service_payload_with_nested_typespb.MethodPayloadWithNestedTypesResponse{} return message @@ -51,10 +51,10 @@ func ValidateArrayOfString(val *service_payload_with_nested_typespb.ArrayOfStrin return } -// protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams -// builds a value of type *servicepayloadwithnestedtypes.AParams from a value -// of type *service_payload_with_nested_typespb.AParams. -func protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams(v *service_payload_with_nested_typespb.AParams) *servicepayloadwithnestedtypes.AParams { +// transformProtoAParamsToAParams builds a value of type +// *servicepayloadwithnestedtypes.AParams from a value of type +// *service_payload_with_nested_typespb.AParams. +func transformProtoAParamsToAParams(v *service_payload_with_nested_typespb.AParams) *servicepayloadwithnestedtypes.AParams { if v == nil { return nil } @@ -74,10 +74,10 @@ func protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtyp return res } -// protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams -// builds a value of type *servicepayloadwithnestedtypes.BParams from a value -// of type *service_payload_with_nested_typespb.BParams. -func protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams(v *service_payload_with_nested_typespb.BParams) *servicepayloadwithnestedtypes.BParams { +// transformProtoBParamsToBParams builds a value of type +// *servicepayloadwithnestedtypes.BParams from a value of type +// *service_payload_with_nested_typespb.BParams. +func transformProtoBParamsToBParams(v *service_payload_with_nested_typespb.BParams) *servicepayloadwithnestedtypes.BParams { if v == nil { return nil } @@ -93,47 +93,3 @@ func protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtyp return res } - -// svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams -// builds a value of type *service_payload_with_nested_typespb.AParams from a -// value of type *servicepayloadwithnestedtypes.AParams. -func svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams(v *servicepayloadwithnestedtypes.AParams) *service_payload_with_nested_typespb.AParams { - if v == nil { - return nil - } - res := &service_payload_with_nested_typespb.AParams{} - if v.A != nil { - res.A = make(map[string]*service_payload_with_nested_typespb.ArrayOfString, len(v.A)) - for key, val := range v.A { - tk := key - tv := &service_payload_with_nested_typespb.ArrayOfString{} - tv.Field = make([]string, len(val)) - for i, val := range val { - tv.Field[i] = val - } - res.A[tk] = tv - } - } - - return res -} - -// svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams -// builds a value of type *service_payload_with_nested_typespb.BParams from a -// value of type *servicepayloadwithnestedtypes.BParams. -func svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams(v *servicepayloadwithnestedtypes.BParams) *service_payload_with_nested_typespb.BParams { - if v == nil { - return nil - } - res := &service_payload_with_nested_typespb.BParams{} - if v.B != nil { - res.B = make(map[string]string, len(v.B)) - for key, val := range v.B { - tk := key - tv := val - res.B[tk] = tv - } - } - - return res -} diff --git a/grpc/codegen/testdata/golden/server_types_server-required-union-validation.go.golden b/grpc/codegen/testdata/golden/server_types_server-required-union-validation.go.golden new file mode 100644 index 0000000000..7f46ae1c08 --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-required-union-validation.go.golden @@ -0,0 +1,65 @@ +// ValidateExchangeRequest runs the validations defined on ExchangeRequest. +func ValidateExchangeRequest(message *union_validationpb.ExchangeRequest) (err error) { + if message.Choice == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("choice", "message")) + } + switch v := message.Choice.(type) { + case *union_validationpb.ExchangeRequest_Number: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("number", "message.choice")) + break + } + if int(v.Number) < 1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("message.choice.value", int(v.Number), 1, true)) + } + + case *union_validationpb.ExchangeRequest_Detail: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("detail", "message.choice")) + break + } + if v.Detail == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("detail", "message.choice")) + break + } + + case *union_validationpb.ExchangeRequest_Inactive: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("inactive", "message.choice")) + break + } + if v.Inactive == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("inactive", "message.choice")) + break + } + + case *union_validationpb.ExchangeRequest_Blob: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("blob", "message.choice")) + break + } + if v.Blob == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("blob", "message.choice")) + break + } + + case *union_validationpb.ExchangeRequest_Token: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("token", "message.choice")) + break + } + + case *union_validationpb.ExchangeRequest_Metadata: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("metadata", "message.choice")) + break + } + if v.Metadata == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("metadata", "message.choice")) + break + } + + } + + return +} diff --git a/grpc/codegen/testdata/golden/server_types_server-result-collection.go.golden b/grpc/codegen/testdata/golden/server_types_server-result-collection.go.golden index b6f63e4a56..72bc0013aa 100644 --- a/grpc/codegen/testdata/golden/server_types_server-result-collection.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-result-collection.go.golden @@ -1,18 +1,18 @@ -// NewProtoMethodResultWithCollectionResponse builds the gRPC response type -// from the result of the "MethodResultWithCollection" endpoint of the -// "ServiceResultWithCollection" service. +// NewProtoMethodResultWithCollectionResponse builds +// *service_result_with_collectionpb.MethodResultWithCollectionResponse from +// *serviceresultwithcollection.MethodResultWithCollectionResult. func NewProtoMethodResultWithCollectionResponse(result *serviceresultwithcollection.MethodResultWithCollectionResult) *service_result_with_collectionpb.MethodResultWithCollectionResponse { message := &service_result_with_collectionpb.MethodResultWithCollectionResponse{} if result.Result != nil { - message.Result = svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT(result.Result) + message.Result = transformResultTToProtoResultT(result.Result) } return message } -// svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT -// builds a value of type *service_result_with_collectionpb.ResultT from a -// value of type *serviceresultwithcollection.ResultT. -func svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT(v *serviceresultwithcollection.ResultT) *service_result_with_collectionpb.ResultT { +// transformResultTToProtoResultT builds a value of type +// *service_result_with_collectionpb.ResultT from a value of type +// *serviceresultwithcollection.ResultT. +func transformResultTToProtoResultT(v *serviceresultwithcollection.ResultT) *service_result_with_collectionpb.ResultT { if v == nil { return nil } @@ -31,25 +31,3 @@ func svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT return res } - -// protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT -// builds a value of type *serviceresultwithcollection.ResultT from a value of -// type *service_result_with_collectionpb.ResultT. -func protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT(v *service_result_with_collectionpb.ResultT) *serviceresultwithcollection.ResultT { - if v == nil { - return nil - } - res := &serviceresultwithcollection.ResultT{} - if v.CollectionField != nil { - res.CollectionField = make([]*serviceresultwithcollection.RT, len(v.CollectionField.Field)) - for i, val := range v.CollectionField.Field { - res.CollectionField[i] = &serviceresultwithcollection.RT{} - if val.IntField != nil { - intField := int(*val.IntField) - res.CollectionField[i].IntField = &intField - } - } - } - - return res -} diff --git a/grpc/codegen/testdata/golden/server_types_server-result-with-explicit-view.go.golden b/grpc/codegen/testdata/golden/server_types_server-result-with-explicit-view.go.golden new file mode 100644 index 0000000000..ba47b9cc00 --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-result-with-explicit-view.go.golden @@ -0,0 +1,9 @@ +// NewProtoMethodMessageResultTypeWithExplicitViewResponse builds +// *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse +// from *servicemessageresulttypewithexplicitviewviews.RTView. +func NewProtoMethodMessageResultTypeWithExplicitViewResponse(result *servicemessageresulttypewithexplicitviewviews.RTView) *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse { + message := &service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse{ + IntField: int32(*result.IntField), + } + return message +} diff --git a/grpc/codegen/testdata/golden/server_types_server-result-with-views.go.golden b/grpc/codegen/testdata/golden/server_types_server-result-with-views.go.golden new file mode 100644 index 0000000000..94b3aa57ad --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-result-with-views.go.golden @@ -0,0 +1,20 @@ +// NewProtoMethodMessageResultTypeWithViewsResponse builds +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse +// from *servicemessageresulttypewithviewsviews.RTView. +func NewProtoMethodMessageResultTypeWithViewsResponse(result *servicemessageresulttypewithviewsviews.RTView) *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse { + message := &service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse{ + IntField: int32(*result.IntField), + StringField: *result.StringField, + } + return message +} + +// NewProtoMethodMessageResultTypeWithViewsResponseTiny builds +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse +// from *servicemessageresulttypewithviewsviews.RTView. +func NewProtoMethodMessageResultTypeWithViewsResponseTiny(result *servicemessageresulttypewithviewsviews.RTView) *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse { + message := &service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse{ + IntField: int32(*result.IntField), + } + return message +} diff --git a/grpc/codegen/testdata/golden/server_types_server-streaming-result-with-views.go.golden b/grpc/codegen/testdata/golden/server_types_server-streaming-result-with-views.go.golden new file mode 100644 index 0000000000..ff83835048 --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-streaming-result-with-views.go.golden @@ -0,0 +1,20 @@ +// NewProtoMethodServerStreamingUserTypeRPCResponse builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse +// from *serviceserverstreamingusertyperpcviews.ResultTypeView. +func NewProtoMethodServerStreamingUserTypeRPCResponse(result *serviceserverstreamingusertyperpcviews.ResultTypeView) *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse{ + IntField: int32(*result.IntField), + DoubleField: *result.DoubleField, + } + return message +} + +// NewProtoMethodServerStreamingUserTypeRPCResponseTiny builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse +// from *serviceserverstreamingusertyperpcviews.ResultTypeView. +func NewProtoMethodServerStreamingUserTypeRPCResponseTiny(result *serviceserverstreamingusertyperpcviews.ResultTypeView) *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse{ + IntField: int32(*result.IntField), + } + return message +} diff --git a/grpc/codegen/testdata/golden/server_types_server-struct-field-name-meta-type.go.golden b/grpc/codegen/testdata/golden/server_types_server-struct-field-name-meta-type.go.golden index c0146338fd..6d90ef7b6a 100644 --- a/grpc/codegen/testdata/golden/server_types_server-struct-field-name-meta-type.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-struct-field-name-meta-type.go.golden @@ -1,5 +1,5 @@ -// NewMethodPayload builds the payload of the "Method" endpoint of the -// "UsingMetaTypes" service from the gRPC request type. +// NewMethodPayload builds *usingmetatypes.MethodPayload from +// *using_meta_typespb.MethodRequest. func NewMethodPayload(message *using_meta_typespb.MethodRequest) *usingmetatypes.MethodPayload { v := &usingmetatypes.MethodPayload{} if message.A != nil { @@ -17,8 +17,8 @@ func NewMethodPayload(message *using_meta_typespb.MethodRequest) *usingmetatypes return v } -// NewProtoMethodResponse builds the gRPC response type from the result of the -// "Method" endpoint of the "UsingMetaTypes" service. +// NewProtoMethodResponse builds *using_meta_typespb.MethodResponse from +// *usingmetatypes.MethodResult. func NewProtoMethodResponse(result *usingmetatypes.MethodResult) *using_meta_typespb.MethodResponse { message := &using_meta_typespb.MethodResponse{ A: &result.Foo, diff --git a/grpc/codegen/testdata/golden/server_types_server-struct-meta-type.go.golden b/grpc/codegen/testdata/golden/server_types_server-struct-meta-type.go.golden index d222a7cbf1..b70e2e4a99 100644 --- a/grpc/codegen/testdata/golden/server_types_server-struct-meta-type.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-struct-meta-type.go.golden @@ -1,5 +1,5 @@ -// NewMethodPayload builds the payload of the "Method" endpoint of the -// "UsingMetaTypes" service from the gRPC request type. +// NewMethodPayload builds *usingmetatypes.MethodPayload from +// *using_meta_typespb.MethodRequest. func NewMethodPayload(message *using_meta_typespb.MethodRequest) *usingmetatypes.MethodPayload { v := &usingmetatypes.MethodPayload{} if message.A != nil { @@ -27,8 +27,8 @@ func NewMethodPayload(message *using_meta_typespb.MethodRequest) *usingmetatypes return v } -// NewProtoMethodResponse builds the gRPC response type from the result of the -// "Method" endpoint of the "UsingMetaTypes" service. +// NewProtoMethodResponse builds *using_meta_typespb.MethodResponse from +// *usingmetatypes.MethodResult. func NewProtoMethodResponse(result *usingmetatypes.MethodResult) *using_meta_typespb.MethodResponse { message := &using_meta_typespb.MethodResponse{} a := int64(result.A) diff --git a/grpc/codegen/testdata/golden/server_types_server-with-errors.go.golden b/grpc/codegen/testdata/golden/server_types_server-with-errors.go.golden index 25c09c5c8b..e72d3fb8c1 100644 --- a/grpc/codegen/testdata/golden/server_types_server-with-errors.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-with-errors.go.golden @@ -1,23 +1,22 @@ -// NewMethodUnaryRPCWithErrorsPayload builds the payload of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC request type. +// NewMethodUnaryRPCWithErrorsPayload builds string from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest. func NewMethodUnaryRPCWithErrorsPayload(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest) string { v := message.Field return v } -// NewProtoMethodUnaryRPCWithErrorsResponse builds the gRPC response type from -// the result of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewProtoMethodUnaryRPCWithErrorsResponse builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse from +// string. func NewProtoMethodUnaryRPCWithErrorsResponse(result string) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse{} message.Field = result return message } -// NewMethodUnaryRPCWithErrorsInternalError builds the gRPC error response type -// from the error of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewMethodUnaryRPCWithErrorsInternalError builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError from +// *serviceunaryrpcwitherrors.AnotherError. func NewMethodUnaryRPCWithErrorsInternalError(er *serviceunaryrpcwitherrors.AnotherError) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError{ Name: er.Name, @@ -26,9 +25,9 @@ func NewMethodUnaryRPCWithErrorsInternalError(er *serviceunaryrpcwitherrors.Anot return message } -// NewMethodUnaryRPCWithErrorsBadRequestError builds the gRPC error response -// type from the error of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewMethodUnaryRPCWithErrorsBadRequestError builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError +// from *serviceunaryrpcwitherrors.AnotherError. func NewMethodUnaryRPCWithErrorsBadRequestError(er *serviceunaryrpcwitherrors.AnotherError) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError{ Name: er.Name, @@ -37,9 +36,9 @@ func NewMethodUnaryRPCWithErrorsBadRequestError(er *serviceunaryrpcwitherrors.An return message } -// NewMethodUnaryRPCWithErrorsCustomErrorError builds the gRPC error response -// type from the error of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewMethodUnaryRPCWithErrorsCustomErrorError builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError +// from *serviceunaryrpcwitherrors.ErrorType. func NewMethodUnaryRPCWithErrorsCustomErrorError(er *serviceunaryrpcwitherrors.ErrorType) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError{ A: er.A, diff --git a/grpc/codegen/testdata/golden/viewed_result_dynamic_response_encoder.go.golden b/grpc/codegen/testdata/golden/viewed_result_dynamic_response_encoder.go.golden new file mode 100644 index 0000000000..afd26b1453 --- /dev/null +++ b/grpc/codegen/testdata/golden/viewed_result_dynamic_response_encoder.go.golden @@ -0,0 +1,21 @@ +// EncodeMethodMessageResultTypeWithViewsResponse encodes responses from the +// "ServiceMessageResultTypeWithViews" service +// "MethodMessageResultTypeWithViews" endpoint. +func EncodeMethodMessageResultTypeWithViewsResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(*servicemessageresulttypewithviewsviews.RT) + if !ok { + return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithViews", "MethodMessageResultTypeWithViews", "*servicemessageresulttypewithviewsviews.RT", v) + } + result := vres.Projected + var resp *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse + switch vres.View { + case "tiny": + resp = NewProtoMethodMessageResultTypeWithViewsResponseTiny(result) + case "default", "": + resp = NewProtoMethodMessageResultTypeWithViewsResponse(result) + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{"tiny", "default"}) + } + (*hdr).Append("goa-view", vres.View) + return resp, nil +} diff --git a/grpc/codegen/testdata/golden/viewed_result_dynamic_stream_send.go.golden b/grpc/codegen/testdata/golden/viewed_result_dynamic_stream_send.go.golden new file mode 100644 index 0000000000..4ce4cfd685 --- /dev/null +++ b/grpc/codegen/testdata/golden/viewed_result_dynamic_stream_send.go.golden @@ -0,0 +1,36 @@ +// Send streams instances of +// "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" +// to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream. +func (s *MethodServerStreamingUserTypeRPCServerStream) Send(res *serviceserverstreamingusertyperpc.ResultType) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + vres := serviceserverstreamingusertyperpc.NewViewedResultType(res, view) + var v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse + switch view { + case "tiny": + v = NewProtoMethodServerStreamingUserTypeRPCResponseTiny(vres.Projected) + case "default", "": + v = NewProtoMethodServerStreamingUserTypeRPCResponse(vres.Projected) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "default"}) + } + if s.sentView == "" { + if err := s.stream.SetHeader(metadata.Pairs("goa-view", view)); err != nil { + return err + } + s.sentView = view + } + return s.stream.Send(v) +} + +// SendWithContext streams instances of +// "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" +// to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream with context. +func (s *MethodServerStreamingUserTypeRPCServerStream) SendWithContext(ctx context.Context, res *serviceserverstreamingusertyperpc.ResultType) error { + return s.Send(res) +} diff --git a/grpc/codegen/testdata/golden/viewed_result_fixed_response_encoder.go.golden b/grpc/codegen/testdata/golden/viewed_result_fixed_response_encoder.go.golden new file mode 100644 index 0000000000..b8aa816b08 --- /dev/null +++ b/grpc/codegen/testdata/golden/viewed_result_fixed_response_encoder.go.golden @@ -0,0 +1,13 @@ +// EncodeMethodMessageResultTypeWithExplicitViewResponse encodes responses from +// the "ServiceMessageResultTypeWithExplicitView" service +// "MethodMessageResultTypeWithExplicitView" endpoint. +func EncodeMethodMessageResultTypeWithExplicitViewResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(*servicemessageresulttypewithexplicitviewviews.RT) + if !ok { + return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithExplicitView", "MethodMessageResultTypeWithExplicitView", "*servicemessageresulttypewithexplicitviewviews.RT", v) + } + result := vres.Projected + resp := NewProtoMethodMessageResultTypeWithExplicitViewResponse(result) + (*hdr).Append("goa-view", "tiny") + return resp, nil +} diff --git a/grpc/codegen/testdata/protoc_names.proto b/grpc/codegen/testdata/protoc_names.proto new file mode 100644 index 0000000000..3e4c038432 --- /dev/null +++ b/grpc/codegen/testdata/protoc_names.proto @@ -0,0 +1,78 @@ +// This schema records the Go names produced by Goa's supported protobuf tools. +syntax = "proto3"; + +package goa.names.v1; + +option go_package = "goa.design/goa/v3/grpc/codegen/testdata/protocnames;protocnames"; + +enum http_2_status { + HTTP_2_STATUS_UNSPECIFIED = 0; + HTTP2_OK = 1; + URL_2_READY = 2; +} + +message api2_http_request { + string api_url = 1; + string api2_url = 2; + string url2_id = 3; + string dns_2_server = 4; + string x509_cert = 5; + string type = 6; + string func = 7; + string var = 8; + string range = 9; + string reset = 10; + string string = 11; + string proto_message = 12; + string descriptor = 13; + string marshal = 14; + string unmarshal = 15; + string extension_range_array = 16; + string extension_map = 17; + string get_api_url = 18; + string message_ = 19; + string service_ = 20; + + oneof result_2_kind { + string http_2xx = 21; + nested_api2 api_url_value = 22; + bytes package_ = 23; + } + + message nested_api2 { + enum state_2 { + STATE_2_UNSPECIFIED = 0; + DNS2_READY = 1; + URL_2_READY = 2; + } + + state_2 state = 1; + } +} + +message wrapper_conflict { + message _BranchValue { + } + + string choice_value = 1; + + oneof choiceValue { + _BranchValue branchValue = 2; + string reset = 3; + } +} + +message Explicit_HTTP2_Name { + string Explicit_Field2_Name = 1; +} + +message stream_reply { + http_2_status status = 1; +} + +service api2_http_service { + rpc get_url2(api2_http_request) returns (stream_reply); + rpc watch_dns2(api2_http_request) returns (stream stream_reply); + rpc upload_api2(stream api2_http_request) returns (stream_reply); + rpc sync_x509(stream api2_http_request) returns (stream stream_reply); +} diff --git a/grpc/codegen/testdata/request_encoder_code.go b/grpc/codegen/testdata/request_encoder_code.go deleted file mode 100644 index 1251cba5bc..0000000000 --- a/grpc/codegen/testdata/request_encoder_code.go +++ /dev/null @@ -1,128 +0,0 @@ -package testdata - -const PayloadUserTypeRequestEncoderCode = `// EncodeMethodMessageUserTypeWithNestedUserTypesRequest encodes requests sent -// to ServiceMessageUserTypeWithNestedUserTypes -// MethodMessageUserTypeWithNestedUserTypes endpoint. -func EncodeMethodMessageUserTypeWithNestedUserTypesRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicemessageusertypewithnestedusertypes.UT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageUserTypeWithNestedUserTypes", "MethodMessageUserTypeWithNestedUserTypes", "*servicemessageusertypewithnestedusertypes.UT", v) - } - return NewProtoMethodMessageUserTypeWithNestedUserTypesRequest(payload), nil -} -` - -const PayloadArrayRequestEncoderCode = `// EncodeMethodUnaryRPCNoResultRequest encodes requests sent to -// ServiceUnaryRPCNoResult MethodUnaryRPCNoResult endpoint. -func EncodeMethodUnaryRPCNoResultRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.([]string) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceUnaryRPCNoResult", "MethodUnaryRPCNoResult", "[]string", v) - } - return NewProtoMethodUnaryRPCNoResultRequest(payload), nil -} -` - -const PayloadMapRequestEncoderCode = `// EncodeMethodMessageMapRequest encodes requests sent to ServiceMessageMap -// MethodMessageMap endpoint. -func EncodeMethodMessageMapRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(map[int]*servicemessagemap.UT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageMap", "MethodMessageMap", "map[int]*servicemessagemap.UT", v) - } - return NewProtoMethodMessageMapRequest(payload), nil -} -` - -const PayloadPrimitiveRequestEncoderCode = `// EncodeMethodServerStreamingRPCRequest encodes requests sent to -// ServiceServerStreamingRPC MethodServerStreamingRPC endpoint. -func EncodeMethodServerStreamingRPCRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(int) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceServerStreamingRPC", "MethodServerStreamingRPC", "int", v) - } - return NewProtoMethodServerStreamingRPCRequest(payload), nil -} -` - -const PayloadPrimitiveWithStreamingPayloadRequestEncoderCode = `// EncodeMethodClientStreamingRPCWithPayloadRequest encodes requests sent to -// ServiceClientStreamingRPCWithPayload MethodClientStreamingRPCWithPayload -// endpoint. -func EncodeMethodClientStreamingRPCWithPayloadRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(int) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceClientStreamingRPCWithPayload", "MethodClientStreamingRPCWithPayload", "int", v) - } - (*md).Append("goa_payload", fmt.Sprintf("%v", payload)) - return nil, nil -} -` - -const PayloadUserTypeWithStreamingPayloadRequestEncoderCode = `// EncodeMethodBidirectionalStreamingRPCWithPayloadRequest encodes requests -// sent to ServiceBidirectionalStreamingRPCWithPayload -// MethodBidirectionalStreamingRPCWithPayload endpoint. -func EncodeMethodBidirectionalStreamingRPCWithPayloadRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicebidirectionalstreamingrpcwithpayload.Payload) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceBidirectionalStreamingRPCWithPayload", "MethodBidirectionalStreamingRPCWithPayload", "*servicebidirectionalstreamingrpcwithpayload.Payload", v) - } - if payload.A != nil { - (*md).Append("a", fmt.Sprintf("%v", *payload.A)) - } - if payload.B != nil { - (*md).Append("b", *payload.B) - } - return nil, nil -} -` - -const PayloadWithMetadataRequestEncoderCode = `// EncodeMethodMessageWithMetadataRequest encodes requests sent to -// ServiceMessageWithMetadata MethodMessageWithMetadata endpoint. -func EncodeMethodMessageWithMetadataRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicemessagewithmetadata.RequestUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithMetadata", "MethodMessageWithMetadata", "*servicemessagewithmetadata.RequestUT", v) - } - if payload.InMetadata != nil { - (*md).Append("Authorization", fmt.Sprintf("%v", *payload.InMetadata)) - } - return NewProtoMethodMessageWithMetadataRequest(payload), nil -} -` - -const PayloadWithValidateRequestEncoderCode = `// EncodeMethodMessageWithValidateRequest encodes requests sent to -// ServiceMessageWithValidate MethodMessageWithValidate endpoint. -func EncodeMethodMessageWithValidateRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicemessagewithvalidate.RequestUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithValidate", "MethodMessageWithValidate", "*servicemessagewithvalidate.RequestUT", v) - } - if payload.InMetadata != nil { - (*md).Append("Authorization", fmt.Sprintf("%v", *payload.InMetadata)) - } - return NewProtoMethodMessageWithValidateRequest(payload), nil -} -` - -const PayloadWithSecurityAttrsRequestEncoderCode = `// EncodeMethodMessageWithSecurityRequest encodes requests sent to -// ServiceMessageWithSecurity MethodMessageWithSecurity endpoint. -func EncodeMethodMessageWithSecurityRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicemessagewithsecurity.RequestUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithSecurity", "MethodMessageWithSecurity", "*servicemessagewithsecurity.RequestUT", v) - } - if payload.Token != nil { - (*md).Append("authorization", *payload.Token) - } - if payload.Key != nil { - (*md).Append("authorization", *payload.Key) - } - if payload.Username != nil { - (*md).Append("username", *payload.Username) - } - if payload.Password != nil { - (*md).Append("password", *payload.Password) - } - return NewProtoMethodMessageWithSecurityRequest(payload), nil -} -` diff --git a/grpc/codegen/testdata/response_encoder_code.go b/grpc/codegen/testdata/response_encoder_code.go deleted file mode 100644 index 770c6fb81c..0000000000 --- a/grpc/codegen/testdata/response_encoder_code.go +++ /dev/null @@ -1,118 +0,0 @@ -package testdata - -const EmptyResultResponseEncoderCode = `// EncodeMethodUnaryRPCNoResultResponse encodes responses from the -// "ServiceUnaryRPCNoResult" service "MethodUnaryRPCNoResult" endpoint. -func EncodeMethodUnaryRPCNoResultResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - resp := NewProtoMethodUnaryRPCNoResultResponse() - return resp, nil -} -` - -const ResultWithViewsResponseEncoderCode = `// EncodeMethodMessageResultTypeWithViewsResponse encodes responses from the -// "ServiceMessageResultTypeWithViews" service -// "MethodMessageResultTypeWithViews" endpoint. -func EncodeMethodMessageResultTypeWithViewsResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - vres, ok := v.(*servicemessageresulttypewithviewsviews.RT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithViews", "MethodMessageResultTypeWithViews", "*servicemessageresulttypewithviewsviews.RT", v) - } - result := vres.Projected - (*hdr).Append("goa-view", vres.View) - resp := NewProtoMethodMessageResultTypeWithViewsResponse(result) - return resp, nil -} -` - -const ResultWithExplicitViewResponseEncoderCode = `// EncodeMethodMessageResultTypeWithExplicitViewResponse encodes responses from -// the "ServiceMessageResultTypeWithExplicitView" service -// "MethodMessageResultTypeWithExplicitView" endpoint. -func EncodeMethodMessageResultTypeWithExplicitViewResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - vres, ok := v.(*servicemessageresulttypewithexplicitviewviews.RT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithExplicitView", "MethodMessageResultTypeWithExplicitView", "*servicemessageresulttypewithexplicitviewviews.RT", v) - } - result := vres.Projected - (*hdr).Append("goa-view", vres.View) - resp := NewProtoMethodMessageResultTypeWithExplicitViewResponse(result) - return resp, nil -} -` - -const ResultArrayResponseEncoderCode = `// EncodeMethodMessageArrayResponse encodes responses from the -// "ServiceMessageArray" service "MethodMessageArray" endpoint. -func EncodeMethodMessageArrayResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - result, ok := v.([]*servicemessagearray.UT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageArray", "MethodMessageArray", "[]*servicemessagearray.UT", v) - } - resp := NewProtoMethodMessageArrayResponse(result) - return resp, nil -} -` - -const ResultPrimitiveResponseEncoderCode = `// EncodeMethodUnaryRPCNoPayloadResponse encodes responses from the -// "ServiceUnaryRPCNoPayload" service "MethodUnaryRPCNoPayload" endpoint. -func EncodeMethodUnaryRPCNoPayloadResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - result, ok := v.(string) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceUnaryRPCNoPayload", "MethodUnaryRPCNoPayload", "string", v) - } - resp := NewProtoMethodUnaryRPCNoPayloadResponse(result) - return resp, nil -} -` - -const ResultWithMetadataResponseEncoderCode = `// EncodeMethodMessageWithMetadataResponse encodes responses from the -// "ServiceMessageWithMetadata" service "MethodMessageWithMetadata" endpoint. -func EncodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - result, ok := v.(*servicemessagewithmetadata.ResponseUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithMetadata", "MethodMessageWithMetadata", "*servicemessagewithmetadata.ResponseUT", v) - } - resp := NewProtoMethodMessageWithMetadataResponse(result) - - if res.InHeader != nil { - (*hdr).Append("Location", fmt.Sprintf("%v", *p.InHeader)) - } - - if res.InTrailer != nil { - (*trlr).Append("InTrailer", fmt.Sprintf("%v", *p.InTrailer)) - } - return resp, nil -} -` - -const ResultWithValidateResponseEncoderCode = `// EncodeMethodMessageWithValidateResponse encodes responses from the -// "ServiceMessageWithValidate" service "MethodMessageWithValidate" endpoint. -func EncodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - result, ok := v.(*servicemessagewithvalidate.ResponseUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithValidate", "MethodMessageWithValidate", "*servicemessagewithvalidate.ResponseUT", v) - } - resp := NewProtoMethodMessageWithValidateResponse(result) - - if res.InHeader != nil { - (*hdr).Append("Location", fmt.Sprintf("%v", *p.InHeader)) - } - - if res.InTrailer != nil { - (*trlr).Append("InTrailer", fmt.Sprintf("%v", *p.InTrailer)) - } - return resp, nil -} -` - -const ResultCollectionResponseEncoderCode = `// EncodeMethodMessageUserTypeWithNestedUserTypesResponse encodes responses -// from the "ServiceMessageUserTypeWithNestedUserTypes" service -// "MethodMessageUserTypeWithNestedUserTypes" endpoint. -func EncodeMethodMessageUserTypeWithNestedUserTypesResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - vres, ok := v.(servicemessageusertypewithnestedusertypesviews.RTCollection) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageUserTypeWithNestedUserTypes", "MethodMessageUserTypeWithNestedUserTypes", "servicemessageusertypewithnestedusertypesviews.RTCollection", v) - } - result := vres.Projected - (*hdr).Append("goa-view", vres.View) - resp := NewProtoRTCollection(result) - return resp, nil -} -` diff --git a/grpc/codegen/testdata/server-no-server.golden b/grpc/codegen/testdata/server-no-server.golden index 5b125b7f57..0205cc92d3 100644 --- a/grpc/codegen/testdata/server-no-server.golden +++ b/grpc/codegen/testdata/server-no-server.golden @@ -25,12 +25,7 @@ func handleGRPCServer(ctx context.Context, u *url.URL, serviceEndpoints *service // Register the servers. servicepb.RegisterServiceServer(srv, serviceServer) - - for svc, info := range srv.GetServiceInfo() { - for _, m := range info.Methods { - log.Printf(ctx, "serving gRPC method %s", svc+"/"+m.Name) - } - } + log.Printf(ctx, "serving gRPC method %s", "service.Service/Method") // Register the server reflection service on the server. // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. diff --git a/grpc/codegen/testdata/server-server-hosting-multiple-services.golden b/grpc/codegen/testdata/server-server-hosting-multiple-services.golden index e9311a5676..6082624304 100644 --- a/grpc/codegen/testdata/server-server-hosting-multiple-services.golden +++ b/grpc/codegen/testdata/server-server-hosting-multiple-services.golden @@ -28,12 +28,8 @@ func handleGRPCServer(ctx context.Context, u *url.URL, serviceEndpoints *service // Register the servers. servicepb.RegisterServiceServer(srv, serviceServer) another_servicepb.RegisterAnotherServiceServer(srv, anotherServiceServer) - - for svc, info := range srv.GetServiceInfo() { - for _, m := range info.Methods { - log.Printf(ctx, "serving gRPC method %s", svc+"/"+m.Name) - } - } + log.Printf(ctx, "serving gRPC method %s", "service.Service/Method") + log.Printf(ctx, "serving gRPC method %s", "another_service.AnotherService/Method") // Register the server reflection service on the server. // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. diff --git a/grpc/codegen/testdata/server-server-hosting-service-subset.golden b/grpc/codegen/testdata/server-server-hosting-service-subset.golden index 5b125b7f57..0205cc92d3 100644 --- a/grpc/codegen/testdata/server-server-hosting-service-subset.golden +++ b/grpc/codegen/testdata/server-server-hosting-service-subset.golden @@ -25,12 +25,7 @@ func handleGRPCServer(ctx context.Context, u *url.URL, serviceEndpoints *service // Register the servers. servicepb.RegisterServiceServer(srv, serviceServer) - - for svc, info := range srv.GetServiceInfo() { - for _, m := range info.Methods { - log.Printf(ctx, "serving gRPC method %s", svc+"/"+m.Name) - } - } + log.Printf(ctx, "serving gRPC method %s", "service.Service/Method") // Register the server reflection service on the server. // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. diff --git a/grpc/codegen/testdata/streaming_code.go b/grpc/codegen/testdata/streaming_code.go index 0a13907ff6..e2851c732b 100644 --- a/grpc/codegen/testdata/streaming_code.go +++ b/grpc/codegen/testdata/streaming_code.go @@ -1,3 +1,4 @@ +// This file contains expected gRPC stream code used by generator tests. package testdata var ServerStreamingServerStructCode = `// MethodServerStreamingUserTypeRPCServerStream implements the @@ -12,7 +13,7 @@ var ServerStreamingServerSendCode = `// Send streams instances of // "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" // to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream. func (s *MethodServerStreamingUserTypeRPCServerStream) Send(res *serviceserverstreamingusertyperpc.UserType) error { - v := NewProtoUserTypeMethodServerStreamingUserTypeRPCResponse(res) + v := NewProtoMethodServerStreamingUserTypeRPCResponse(res) return s.stream.Send(v) } @@ -65,6 +66,9 @@ var ServerStreamingResultWithViewsServerStructCode = `// MethodServerStreamingUs type MethodServerStreamingUserTypeRPCServerStream struct { stream service_server_streaming_user_type_rpcpb.ServiceServerStreamingUserTypeRPC_MethodServerStreamingUserTypeRPCServer view string + // sentView is the result view named in the response header. Later sends must + // use the same view. + sentView string } ` @@ -72,8 +76,29 @@ var ServerStreamingResultWithViewsServerSendCode = `// Send streams instances of // "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" // to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream. func (s *MethodServerStreamingUserTypeRPCServerStream) Send(res *serviceserverstreamingusertyperpc.ResultType) error { - vres := serviceserverstreamingusertyperpc.NewViewedResultType(res, s.view) - v := NewProtoResultTypeViewMethodServerStreamingUserTypeRPCResponse(vres.Projected) + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + vres := serviceserverstreamingusertyperpc.NewViewedResultType(res, view) + var v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse + switch view { + case "tiny": + v = NewProtoMethodServerStreamingUserTypeRPCResponseTiny(vres.Projected) + case "default", "": + v = NewProtoMethodServerStreamingUserTypeRPCResponse(vres.Projected) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "default"}) + } + if s.sentView == "" { + if err := s.stream.SetHeader(metadata.Pairs("goa-view", view)); err != nil { + return err + } + s.sentView = view + } return s.stream.Send(v) } @@ -95,8 +120,9 @@ var ServerStreamingResultWithViewsClientStructCode = `// MethodServerStreamingUs // serviceserverstreamingusertyperpc.MethodServerStreamingUserTypeRPCClientStream // interface. type MethodServerStreamingUserTypeRPCClientStream struct { - stream service_server_streaming_user_type_rpcpb.ServiceServerStreamingUserTypeRPC_MethodServerStreamingUserTypeRPCClient - view string + stream service_server_streaming_user_type_rpcpb.ServiceServerStreamingUserTypeRPC_MethodServerStreamingUserTypeRPCClient + view string + viewSet bool } ` @@ -109,7 +135,25 @@ func (s *MethodServerStreamingUserTypeRPCClientStream) Recv() (*serviceserverstr if err != nil { return res, err } - proj := NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v) + if !s.viewSet { + hdr, err := s.stream.Header() + if err != nil { + return res, err + } + views := hdr.Get("goa-view") + if len(views) == 0 { + return res, goa.MissingFieldError("goa-view", "metadata") + } + s.view = views[0] + s.viewSet = true + } + var proj *serviceserverstreamingusertyperpcviews.ResultTypeView + switch s.view { + case "tiny": + proj = NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny(v) + case "default", "": + proj = NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v) + } vres := &serviceserverstreamingusertyperpcviews.ResultType{Projected: proj, View: s.view} if err := serviceserverstreamingusertyperpcviews.ValidateResultType(vres); err != nil { return nil, err @@ -129,6 +173,7 @@ func (s *MethodServerStreamingUserTypeRPCClientStream) RecvWithContext(ctx conte var ServerStreamingResultWithViewsClientSetViewCode = `// SetView sets the view. func (s *MethodServerStreamingUserTypeRPCClientStream) SetView(view string) { s.view = view + s.viewSet = true } ` @@ -138,7 +183,7 @@ var ServerStreamingResultCollectionWithExplicitViewServerSendCode = `// Send str // gRPC stream. func (s *MethodServerStreamingResultTypeCollectionWithExplicitViewServerStream) Send(res serviceserverstreamingresulttypecollectionwithexplicitview.ResultTypeCollection) error { vres := serviceserverstreamingresulttypecollectionwithexplicitview.NewViewedResultTypeCollection(res, "tiny") - v := NewProtoResultTypeCollectionViewResultTypeCollection(vres.Projected) + v := NewProtoResultTypeCollection(vres.Projected) return s.stream.Send(v) } @@ -429,7 +474,6 @@ var BidirectionalStreamingServerStructCode = `// MethodBidirectionalStreamingRPC // interface. type MethodBidirectionalStreamingRPCServerStream struct { stream service_bidirectional_streaming_rpcpb.ServiceBidirectionalStreamingRPC_MethodBidirectionalStreamingRPCServer - view string } ` @@ -438,7 +482,7 @@ var BidirectionalStreamingServerSendCode = `// Send streams instances of // to the "MethodBidirectionalStreamingRPC" endpoint gRPC stream. func (s *MethodBidirectionalStreamingRPCServerStream) Send(res *servicebidirectionalstreamingrpc.ID) error { vres := servicebidirectionalstreamingrpc.NewViewedID(res, "default") - v := NewProtoIDViewMethodBidirectionalStreamingRPCResponse(vres.Projected) + v := NewProtoMethodBidirectionalStreamingRPCResponse(vres.Projected) return s.stream.Send(v) } @@ -481,7 +525,6 @@ var BidirectionalStreamingClientStructCode = `// MethodBidirectionalStreamingRPC // interface. type MethodBidirectionalStreamingRPCClientStream struct { stream service_bidirectional_streaming_rpcpb.ServiceBidirectionalStreamingRPC_MethodBidirectionalStreamingRPCClient - view string } ` diff --git a/grpc/codegen/testing.go b/grpc/codegen/testing.go index 103d5c9360..ff5393a9af 100644 --- a/grpc/codegen/testing.go +++ b/grpc/codegen/testing.go @@ -1,3 +1,5 @@ +// This file builds gRPC code-generation analysis in tests using the same +// generation construction, planning, freezing, and rendering as production. package codegen import ( @@ -5,7 +7,9 @@ import ( "testing" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) @@ -17,12 +21,78 @@ func RunGRPCDSL(t *testing.T, dsl func()) *expr.RootExpr { return root } -// CreateGRPCServices creates a new ServicesData instance for testing. The -// root is normalized first like the production Generate flow does before the -// generators read the design. +// CreateGRPCServices creates a new ServicesData instance for testing. +// Generation construction normalizes the root before any planner reads it. func CreateGRPCServices(root *expr.RootExpr) *ServicesData { - codegen.NormalizeRoot(root) - return NewServicesData(service.NewServicesData(root)) + return createServiceServices(root) +} + +// createServiceServices chooses every package name and builds the gRPC service +// data required by transport tests. +func createServiceServices(root *expr.RootExpr) *ServicesData { + return createServiceServicesForPackage(root, "generated.local/gen") +} + +// createServiceServicesForPackage builds test service analysis for the exact +// generated module path whose imports the test renders. +func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *ServicesData { + generation, err := codegen.NewGeneration(genpkg, []eval.Root{root}) + if err != nil { + panic(err) + } + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + if err != nil { + panic(err) + } + grpcPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + if err != nil { + panic(err) + } + if err := generation.Freeze(); err != nil { + panic(err) + } + if err := servicePlan.Link(); err != nil { + panic(err) + } + if err := grpcPlans[0].Link(); err != nil { + panic(err) + } + return grpcPlans[0].services +} + +// createExamplePlan builds linked gRPC data and copied server data that belong +// to the same service plan. +func createExamplePlan(root *expr.RootExpr, genpkg string) *ExamplePlan { + generation, err := codegen.NewGeneration(genpkg, []eval.Root{root}) + if err != nil { + panic(err) + } + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + if err != nil { + panic(err) + } + grpcPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + if err != nil { + panic(err) + } + examplePlan, err := example.NewPlan(generation, servicePlan) + if err != nil { + panic(err) + } + examples, err := NewExamplePlan(grpcPlans[0], examplePlan) + if err != nil { + panic(err) + } + if err := generation.Freeze(); err != nil { + panic(err) + } + if err := servicePlan.Link(); err != nil { + panic(err) + } + if err := grpcPlans[0].Link(); err != nil { + panic(err) + } + return examples } func sectionCode(t *testing.T, section ...*codegen.SectionTemplate) string { diff --git a/grpc/codegen/types.go b/grpc/codegen/types.go index f5849111b6..9e33cc8526 100644 --- a/grpc/codegen/types.go +++ b/grpc/codegen/types.go @@ -1,3 +1,5 @@ +// This file renders gRPC client and server conversion types per service and +// attaches imports to the exact side-specific file that uses them. package codegen import ( @@ -5,25 +7,22 @@ import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -// ServerTypeFiles returns the server types files containing all the server -// interfaces and types needed to implement gRPC server. -func ServerTypeFiles(genpkg string, services *ServicesData) []*codegen.File { - fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = typesFile(genpkg, svc, services, true) +// serverTypeFiles returns the planned conversion types used by gRPC servers. +func serverTypeFiles(services *ServicesData) []*codegen.File { + fw := make([]*codegen.File, len(services.servicePlans)) + for i, servicePlan := range services.servicePlans { + fw[i] = addEndpointImports(typesFile(servicePlan, services, true), services, servicePlan) } return fw } -// ClientTypeFiles returns the client types files containing all the client -// interfaces and types needed to implement gRPC client. -func ClientTypeFiles(genpkg string, services *ServicesData) []*codegen.File { - fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = typesFile(genpkg, svc, services, false) +// clientTypeFiles returns the planned conversion types used by gRPC clients. +func clientTypeFiles(services *ServicesData) []*codegen.File { + fw := make([]*codegen.File, len(services.servicePlans)) + for i, servicePlan := range services.servicePlans { + fw[i] = addEndpointImports(typesFile(servicePlan, services, false), services, servicePlan) } return fw } @@ -31,22 +30,23 @@ func ClientTypeFiles(genpkg string, services *ServicesData) []*codegen.File { // typesFile returns the file defining the gRPC types for the given service. // svr indicates whether the file is generated for the server (true) or the // client (false) package. -func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, svr bool) *codegen.File { +func typesFile(servicePlan *grpcServicePlan, services *ServicesData, svr bool) *codegen.File { + svc := servicePlan.expression var ( initData []*InitData sd = services.Get(svc.Name()) ) { - seen := make(map[string]struct{}) + seen := make(map[*codegen.NameDeclaration]struct{}) collect := func(c *ConvertData) { if c == nil || c.Init == nil { return } - if _, ok := seen[c.Init.Name]; ok { + if _, ok := seen[c.Init.Declaration]; ok { return } - seen[c.Init.Name] = struct{}{} + seen[c.Init.Declaration] = struct{}{} initData = append(initData, c.Init) } for _, a := range svc.GRPCEndpoints { @@ -57,8 +57,14 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, collect(ed.Request.LegacyDecode.ServerConvert) } collect(ed.Response.ServerConvert) + for _, conversion := range ed.Response.ServerConverts { + collect(conversion.Convert) + } if ed.ServerStream != nil { collect(ed.ServerStream.SendConvert) + for _, conversion := range ed.ServerStream.SendConverts { + collect(conversion.Convert) + } collect(ed.ServerStream.RecvConvert) } for _, e := range ed.Errors { @@ -67,8 +73,14 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, } else { collect(ed.Request.ClientConvert) collect(ed.Response.ClientConvert) + for _, conversion := range ed.Response.ClientConverts { + collect(conversion.Convert) + } if ed.ClientStream != nil { collect(ed.ClientStream.RecvConvert) + for _, conversion := range ed.ClientStream.RecvConverts { + collect(conversion.Convert) + } collect(ed.ClientStream.SendConvert) } for _, e := range ed.Errors { @@ -91,16 +103,19 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, ) { svcName := sd.Service.PathName + outputPackage := path.Join(services.GenPkg(), "grpc", svcName, side) fpath = filepath.Join(codegen.Gendir, "grpc", svcName, side, "types.go") imports := []*codegen.ImportSpec{ {Path: "unicode/utf8"}, codegen.GoaImport(""), - {Path: path.Join(genpkg, svcName), Name: sd.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: sd.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: sd.PkgName}, + services.ServiceImport(outputPackage, svc.Name()), + services.PackageImport(outputPackage, path.Join(services.GenPkg(), "grpc", svcName, pbPkgName)), + } + if serviceHasViewedResult(sd) { + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } // Add imports if Any type is used - if usesAnyType(svc.GRPCEndpoints, true) { + if servicePlan.usesAnyInErrors { imports = append(imports, &codegen.ImportSpec{Path: "fmt"}) imports = append(imports, &codegen.ImportSpec{Path: "google.golang.org/protobuf/types/known/structpb", Name: "structpb"}) } @@ -111,10 +126,6 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, Name: side + "-type-init", Source: grpcTemplates.Read(grpcTypeInitT), Data: init, - FuncMap: map[string]any{ - "isAlias": expr.IsAlias, - "fullName": fullTypeName, - }, }) } for _, data := range sd.validations { @@ -127,7 +138,11 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, Data: data, }) } - for _, h := range sd.transformHelpers { + helpers := sd.clientTransformHelpers + if svr { + helpers = sd.serverTransformHelpers + } + for _, h := range helpers { sections = append(sections, &codegen.SectionTemplate{ Name: side + "-transform-helper", Source: grpcTemplates.Read(grpcTransformHelperT), @@ -137,12 +152,3 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, } return &codegen.File{Path: fpath, SectionTemplates: sections} } - -// fullTypeName returns the name of the given type qualified with the name of -// its package when the type is defined in an explicit user type location. -func fullTypeName(dt expr.DataType) string { - if loc := codegen.UserTypeLocation(dt); loc != nil { - return loc.PackageName() + "." + dt.Name() - } - return dt.Name() -} diff --git a/grpc/codegen/view_specialization_test.go b/grpc/codegen/view_specialization_test.go new file mode 100644 index 0000000000..e7dec67321 --- /dev/null +++ b/grpc/codegen/view_specialization_test.go @@ -0,0 +1,127 @@ +// This file checks that generated gRPC code keeps design-selected views in +// source and reads transport metadata only when the caller selects the view. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +func TestUnaryViewedResultSpecialization(t *testing.T) { + t.Run("missing or conflicting headers keep design selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.MessageResultTypeWithExplicitViewDSL) + services := CreateGRPCServices(root) + clientFiles := clientFiles(services) + serverFiles := serverFiles(services) + require.Len(t, clientFiles, 2) + require.Len(t, serverFiles, 2) + + decoder := codegen.SectionsCode(t, clientFiles[1].Section("response-decoder")) + assert.NotContains(t, decoder, `hdr.Get("goa-view")`) + assert.Contains(t, decoder, `View: "tiny"`) + assert.NotContains(t, decoder, "switch") + encoder := codegen.SectionsCode(t, serverFiles[1].Section("response-encoder")) + assert.Contains(t, encoder, `Append("goa-view", "tiny")`) + assert.NotContains(t, encoder, `Append("goa-view", vres.View)`) + testutil.AssertGo(t, "testdata/golden/viewed_result_fixed_response_encoder.go.golden", encoder) + response := services.Get("ServiceMessageResultTypeWithExplicitView").Endpoints[0].Response + require.Len(t, response.ClientConverts, 1) + require.Equal(t, "tiny", response.ClientConverts[0].View) + require.Same(t, response.ClientConverts[0].Convert, response.ClientConvert) + }) + + t.Run("caller selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.MessageResultTypeWithViewsDSL) + services := CreateGRPCServices(root) + clientFiles := clientFiles(services) + serverFiles := serverFiles(services) + require.Len(t, clientFiles, 2) + require.Len(t, serverFiles, 2) + + decoder := codegen.SectionsCode(t, clientFiles[1].Section("response-decoder")) + assert.Contains(t, decoder, `hdr.Get("goa-view")`) + assert.Contains(t, decoder, "View: view") + assert.Contains(t, decoder, "switch view") + assert.Contains(t, decoder, "NewMethodMessageResultTypeWithViewsResultTiny(message)") + assert.Contains(t, decoder, "NewMethodMessageResultTypeWithViewsResult(message)") + encoder := codegen.SectionsCode(t, serverFiles[1].Section("response-encoder")) + assert.Contains(t, encoder, `Append("goa-view", vres.View)`) + assert.Contains(t, encoder, `return nil, goa.InvalidEnumValueError("view", vres.View`) + testutil.AssertGo(t, "testdata/golden/viewed_result_dynamic_response_encoder.go.golden", encoder) + response := services.Get("ServiceMessageResultTypeWithViews").Endpoints[0].Response + require.Len(t, response.ServerConverts, 2) + require.Equal(t, "default", response.ServerConverts[1].View) + require.Same(t, response.ServerConverts[1].Convert, response.ServerConvert) + require.Len(t, response.ClientConverts, 2) + require.Equal(t, "default", response.ClientConverts[1].View) + require.Same(t, response.ClientConverts[1].Convert, response.ClientConvert) + }) +} + +func TestStreamingViewedResultSpecialization(t *testing.T) { + t.Run("design selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.ServerStreamingResultCollectionWithExplicitViewDSL) + services := CreateGRPCServices(root) + serverFiles := serverFiles(services) + clientFiles := clientFiles(services) + require.Len(t, serverFiles, 2) + require.Len(t, clientFiles, 2) + + serverStruct := codegen.SectionsCode(t, serverFiles[0].Section("server-stream-struct-type")) + clientStruct := codegen.SectionsCode(t, clientFiles[0].Section("client-stream-struct-type")) + assert.NotContains(t, serverStruct, "\n\tview") + assert.NotContains(t, clientStruct, "\n\tview") + stream := services.Get("ServiceServerStreamingResultTypeCollectionWithExplicitView").Endpoints[0].ClientStream + require.Len(t, stream.RecvConverts, 1) + require.Equal(t, "tiny", stream.RecvConverts[0].View) + require.Same(t, stream.RecvConverts[0].Convert, stream.RecvConvert) + }) + + t.Run("caller selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.ServerStreamingResultWithViewsDSL) + services := CreateGRPCServices(root) + serverFiles := serverFiles(services) + clientFiles := clientFiles(services) + require.Len(t, serverFiles, 2) + require.Len(t, clientFiles, 2) + + serverStruct := codegen.SectionsCode(t, serverFiles[0].Section("server-stream-struct-type")) + clientStruct := codegen.SectionsCode(t, clientFiles[0].Section("client-stream-struct-type")) + assert.Contains(t, serverStruct, "\n\tview") + assert.Contains(t, clientStruct, "\n\tview") + assert.Contains(t, serverStruct, "sentView string") + assert.Contains(t, clientStruct, "viewSet bool") + send := codegen.SectionsCode(t, serverFiles[0].Section("server-stream-send")) + assert.Contains(t, send, `if view == "" {`) + assert.Contains(t, send, `view = "default"`) + assert.Contains(t, send, `if s.sentView != "" && view != s.sentView`) + assert.Contains(t, send, `SetHeader(metadata.Pairs("goa-view", view))`) + assert.Contains(t, send, `return goa.InvalidEnumValueError("view", view`) + require.Less(t, + strings.Index(send, `InvalidEnumValueError("view", view`), + strings.Index(send, `SetHeader(metadata.Pairs("goa-view", view))`), + ) + testutil.AssertGo(t, "testdata/golden/viewed_result_dynamic_stream_send.go.golden", send) + recv := codegen.SectionsCode(t, clientFiles[0].Section("client-stream-recv")) + assert.Contains(t, recv, `s.stream.Header()`) + assert.Contains(t, recv, `goa.MissingFieldError("goa-view", "metadata")`) + assert.Contains(t, recv, "switch s.view") + assert.Contains(t, recv, "NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny(v)") + assert.Contains(t, recv, "NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v)") + stream := services.Get("ServiceServerStreamingUserTypeRPC").Endpoints[0].ServerStream + require.Len(t, stream.SendConverts, 2) + require.Equal(t, "default", stream.SendConverts[1].View) + require.Same(t, stream.SendConverts[1].Convert, stream.SendConvert) + clientStream := services.Get("ServiceServerStreamingUserTypeRPC").Endpoints[0].ClientStream + require.Len(t, clientStream.RecvConverts, 2) + require.Equal(t, "default", clientStream.RecvConverts[1].View) + require.Same(t, clientStream.RecvConverts[1].Convert, clientStream.RecvConvert) + }) +} diff --git a/http/client.go b/http/client.go index 7b447cb156..e117a12908 100644 --- a/http/client.go +++ b/http/client.go @@ -180,7 +180,7 @@ func ErrInvalidURL(svc, m, u string, err error) error { return &ClientError{Name: "invalid_url", Message: msg, Service: svc, Method: m, Err: err} } -// ErrDecodingError is the error returned when the decoder fails to decode the +// ErrDecodingError reports a failure while reading, decoding, or closing a // response body. func ErrDecodingError(svc, m string, err error) error { msg := fmt.Sprintf("failed to decode response body: %s", err) diff --git a/http/codegen/client.go b/http/codegen/client.go index f4efb304e0..0f0bdc81c8 100644 --- a/http/codegen/client.go +++ b/http/codegen/client.go @@ -1,3 +1,5 @@ +// This file renders HTTP client calls and codecs per service; each file owns +// the imports required by the service methods it contains. package codegen import ( @@ -9,32 +11,34 @@ import ( "goa.design/goa/v3/expr" ) -// ClientFiles returns the generated HTTP client files. -func ClientFiles(genpkg string, data *ServicesData) []*codegen.File { +// clientFiles builds the HTTP client files read by Plan.Link. +func clientFiles(data *ServicesData) []*codegen.File { files := make([]*codegen.File, 0, len(data.Expressions.Services)*3) // preallocate for client files for _, svc := range data.Expressions.Services { - files = append(files, clientFile(genpkg, svc, data)) - if f := WebsocketClientFile(genpkg, svc, data); f != nil { - files = append(files, f) + files = append(files, addPlannedFileImports(clientFile(svc, data), data)) + if f := websocketClientFile(svc, data); f != nil { + files = append(files, addPlannedFileImports(f, data)) } - if f := sseClientFile(genpkg, svc, data); f != nil { - files = append(files, f) + if f := sseClientFile(svc, data); f != nil { + files = append(files, addPlannedFileImports(f, data)) } } for _, svc := range data.Expressions.Services { - if f := ClientEncodeDecodeFile(genpkg, svc, data); f != nil { - files = append(files, f) + if f := clientEncodeDecodeFile(svc, data); f != nil { + files = append(files, addPlannedFileImports(f, data)) } } return files } -// ClientEncodeDecodeFile returns the file containing the HTTP client encoding +// clientEncodeDecodeFile returns the file containing the HTTP client encoding // and decoding logic. -func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func clientEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, services.dir(), svcName, "client", "encode_decode.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s %s client encoders and decoders", svc.Name(), services.label()) imports := []*codegen.ImportSpec{ {Path: "bytes"}, @@ -51,8 +55,16 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * {Path: "unicode/utf8"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, + services.ServiceImport(outputPackage, svc.Name()), + } + for _, endpoint := range data.Endpoints { + if !endpoint.Method.SkipResponseBodyEncodeDecode { + imports = append(imports, &codegen.ImportSpec{Path: "errors"}) + break + } + } + if serviceHasViewedResult(data, nil) { + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } for _, e := range data.Endpoints { if e.IsJSONRPC { @@ -72,15 +84,15 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * Source: httpTemplates.Read(requestBuilderT), Data: e, }) - if e.RequestEncoder != "" && (e.Payload.Ref != "" || e.IsJSONRPC) { + if e.RequestEncoderDeclaration != nil && (e.Payload.Ref != "" || e.IsJSONRPC) { sections = append(sections, &codegen.SectionTemplate{ Name: "request-encoder", - Source: httpTemplates.Read(requestEncoderT, clientTypeConversionP, clientMapConversionP, jsonrpcRequestEnvelopeP), + Source: httpTemplates.Read(requestEncoderT, clientTypeExpressionP, clientTypeConversionP, clientMapConversionP, jsonrpcRequestEnvelopeP), FuncMap: map[string]any{ "typeConversionData": typeConversionData, "mapConversionData": mapConversionData, "goTypeRef": func(dt expr.DataType) string { - return services.ServicesData.Get(svc.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return data.Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, "isBearer": isBearer, "aliasedType": fieldType, @@ -94,7 +106,6 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * } return dt }, - "requestStructPkg": requestStructPkg, }, Data: e, }) @@ -112,7 +123,7 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * Data: e, FuncMap: map[string]any{ "goTypeRef": func(dt expr.DataType) string { - return services.ServicesData.Get(svc.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return data.Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, "buildResponseData": buildResponseData, }, @@ -122,9 +133,6 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * Name: "build-stream-request", Source: httpTemplates.Read(buildStreamRequestT), Data: e, - FuncMap: map[string]any{ - "requestStructPkg": requestStructPkg, - }, }) } } @@ -139,28 +147,36 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * return &codegen.File{Path: path, SectionTemplates: sections} } -// clientFile returns the client HTTP transport file -func clientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +// clientFile returns the client HTTP transport file. +func clientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, "http", svcName, "client", "client.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s client HTTP transport", svc.Name()) + imports := []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "fmt"}, + {Path: "io"}, + {Path: "mime/multipart"}, + {Path: "net/http"}, + {Path: "strconv"}, + {Path: "strings"}, + {Path: "time"}, + {Path: "github.com/gorilla/websocket"}, + codegen.GoaImport(""), + codegen.GoaNamedImport("http", "goahttp"), + services.ServiceImport(outputPackage, svc.Name()), + } + for _, endpoint := range data.Endpoints { + if endpoint.SSE != nil || endpoint.Method.SkipResponseBodyEncodeDecode { + imports = append(imports, &codegen.ImportSpec{Path: "errors"}) + break + } + } sections := []*codegen.SectionTemplate{ - codegen.Header(title, "client", []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - {Path: "io"}, - {Path: "mime/multipart"}, - {Path: "net/http"}, - {Path: "strconv"}, - {Path: "strings"}, - {Path: "time"}, - {Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, - }), + codegen.Header(title, "client", imports), } sections = append(sections, &codegen.SectionTemplate{ Name: "client-struct", @@ -211,7 +227,7 @@ func clientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData FuncMap: map[string]any{ "isWebSocketEndpoint": IsWebSocketEndpoint, "isSSEEndpoint": IsSSEEndpoint, - "responseStructPkg": responseStructPkg, + "isServerStreamKind": isServerStreamKind, }, }) } @@ -282,17 +298,3 @@ func isBearer(schemes []*service.SchemeData) bool { } return false } - -func requestStructPkg(m *service.MethodData, def string) string { - if m.PayloadLoc != nil { - return m.PayloadLoc.PackageName() - } - return def -} - -func responseStructPkg(m *service.MethodData, def string) string { - if m.ResultLoc != nil { - return m.ResultLoc.PackageName() - } - return def -} diff --git a/http/codegen/client_body_types_test.go b/http/codegen/client_body_types_test.go index 5dc8ec2660..e9544c6ce8 100644 --- a/http/codegen/client_body_types_test.go +++ b/http/codegen/client_body_types_test.go @@ -8,13 +8,12 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" ) func TestBodyTypeDecl(t *testing.T) { - const genpkg = "gen" - cases := []struct { Name string DSL func() @@ -25,8 +24,8 @@ func TestBodyTypeDecl(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], false, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientTypeFiles()[0] section := fs.SectionTemplates[1] code := codegen.SectionCode(t, section) testutil.AssertGo(t, "testdata/golden/client_body_type_decl_"+c.Name+".go.golden", code) @@ -35,7 +34,6 @@ func TestBodyTypeDecl(t *testing.T) { } func TestBodyTypeInit(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -56,8 +54,8 @@ func TestBodyTypeInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], false, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientTypeFiles()[0] section := fs.SectionTemplates[c.SectionIndex] code := codegen.SectionCode(t, section) testutil.AssertGo(t, "testdata/golden/client_body_type_init_"+c.Name+".go.golden", code) @@ -65,8 +63,41 @@ func TestBodyTypeInit(t *testing.T) { } } +// TestRequiredViewedPrimitiveBodyConstructorUsesProjectedPointer verifies that +// a required JSON string still enters a pointer field in the decoded view so a +// missing value can be reported. +func TestRequiredViewedPrimitiveBodyConstructorUsesProjectedPointer(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.required-viewed-primitive", func() { + dsl.TypeName("RequiredViewedPrimitive") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { dsl.Attribute("value") }) + dsl.View("summary", func() { dsl.Attribute("value") }) + }) + dsl.Service("Values", func() { + dsl.Method("Fetch", func() { + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET("/values") + dsl.Response(dsl.StatusOK, func() { dsl.Body("value") }) + }) + }) + }) + }) + plan := linkedHTTPPlanForRoot(t, root) + file := plan.ClientTypeFiles()[0] + var generated bytes.Buffer + for _, section := range file.SectionTemplates[1:] { + require.NoError(t, section.Write(&generated)) + } + definition := codegen.FormatTestCode(t, "package client\n"+generated.String()) + + require.Contains(t, definition, `func NewFetchRequiredViewedPrimitiveOK(body string) *valuesviews.RequiredViewedPrimitiveView`) + require.Contains(t, definition, "Value: &v,") +} + func TestClientTypes(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -81,6 +112,7 @@ func TestClientTypes(t *testing.T) { {"client-empty-error-response-body", testdata.EmptyErrorResponseBodyDSL}, {"client-with-error-custom-pkg", testdata.WithErrorCustomPkgDSL}, {"client-body-custom-name", testdata.PayloadBodyCustomNameDSL}, + {"client-required-primitive-arrays", testdata.RequiredPrimitiveArrayDSL}, {"client-path-custom-name", testdata.PayloadPathCustomNameDSL}, {"client-query-custom-name", testdata.PayloadQueryCustomNameDSL}, {"client-header-custom-name", testdata.PayloadHeaderCustomNameDSL}, @@ -90,8 +122,8 @@ func TestClientTypes(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], false, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientTypeFiles()[0] var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { require.NoError(t, s.Write(&buf)) @@ -103,7 +135,6 @@ func TestClientTypes(t *testing.T) { } func TestClientTypeFiles(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -113,8 +144,8 @@ func TestClientTypeFiles(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fw := ClientTypeFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fw := plan.ClientTypeFiles() for i, fs := range fw { var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { diff --git a/http/codegen/client_cli.go b/http/codegen/client_cli.go index 3a0a108594..2c7fa6cad6 100644 --- a/http/codegen/client_cli.go +++ b/http/codegen/client_cli.go @@ -1,3 +1,5 @@ +// This file renders HTTP client command parsers and per-service payload +// builders, including imports for relocated payload types used by each builder. package codegen import ( @@ -12,6 +14,7 @@ import ( // commandData wraps the common CommandData and adds HTTP-specific fields. type commandData struct { *cli.CommandData + serviceName string // Subcommands is the list of endpoint commands. Subcommands []*subcommandData // NeedDialer if true initializes the websocket dialer. @@ -20,28 +23,55 @@ type commandData struct { // streaming endpoints are configured with a goahttp.ConnConfigureFunc // instead of a client package ConnConfigurer. JSONRPC bool + // ClientInit is the client constructor called by ParseEndpoint. + ClientInit *codegen.NameDeclaration + // Configurer is the WebSocket configuration type accepted by ParseEndpoint. + Configurer *codegen.NameDeclaration + // ConfigurerLocal is the exact ParseEndpoint parameter that receives the + // WebSocket or JSON-RPC connection configuration. + ConfigurerLocal *cli.ParserLocalData } -// commandData wraps the common SubcommandData and adds HTTP-specific fields. +// subcommandData wraps the common SubcommandData and adds HTTP-specific fields. type subcommandData struct { *cli.SubcommandData - // MultipartFuncName is the name of the function used to render a multipart - // request encoder. + methodName string + // MultipartFuncDeclaration supplies the multipart request encoder type name. + MultipartFuncDeclaration *codegen.NameDeclaration + // MultipartFuncName is the final multipart request encoder name kept for + // existing plugin templates. + // + // Deprecated: Use MultipartFuncDeclaration.Name() after planning. MultipartFuncName string - // MultipartFuncName is the name of the variable used to render a multipart - // request encoder. + // MultipartVarName is the variable that holds the multipart request encoder. MultipartVarName string + // MultipartLocal is the exact ParseEndpoint parameter that receives the + // multipart request encoder. + MultipartLocal *cli.ParserLocalData // StreamFlag is the flag used to identify the file to be streamed when // the endpoint uses SkipRequestBodyEncodeDecode. StreamFlag *cli.FlagData - // BuildStreamPayload is the name of the generated function that builds the - // request data structure that wraps the payload and the file stream for - // endpoints that use SkipRequestBodyEncodeDecode. + // StreamPointerVar is the exact parser variable passed to the stream payload builder. + StreamPointerVar string + // BuildStreamPayloadDeclaration is the generated function that builds the + // request containing the payload and file stream. + BuildStreamPayloadDeclaration *codegen.NameDeclaration + // BuildStreamPayload is the final stream payload helper name kept for + // existing plugin templates. + // + // Deprecated: Use BuildStreamPayloadDeclaration.Name() after planning. BuildStreamPayload string } -// ClientCLIFiles returns the client HTTP CLI support file. +// ClientCLIFiles returns the client HTTP CLI support files. genpkg must match +// the package used to create data. func ClientCLIFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return clientCLIFiles(data) +} + +// clientCLIFiles builds the client command file read by Plan.Link. +func clientCLIFiles(data *ServicesData) []*codegen.File { if len(data.Expressions.Services) == 0 { return nil } @@ -54,8 +84,11 @@ func ClientCLIFiles(genpkg string, data *ServicesData) []*codegen.File { if len(sd.Endpoints) > 0 { command := &commandData{ CommandData: cli.BuildCommandData(sd.Service), + serviceName: sd.Service.Name, NeedDialer: HasWebSocket(sd), JSONRPC: sd.Endpoints[0].IsJSONRPC, + ClientInit: sd.ClientInitDeclaration, + Configurer: sd.ClientConnConfigurerDeclaration, } for _, e := range sd.Endpoints { @@ -80,36 +113,44 @@ func ClientCLIFiles(genpkg string, data *ServicesData) []*codegen.File { } } } - files = append(files, endpointParser(genpkg, data.Root, svr, svrData, data)) + files = append(files, endpointParser(data.Root, svr, svrData, data)) } for i, svc := range svcs { - files = append(files, payloadBuilders(genpkg, svc, cmds[i].CommandData, data)) + files = append(files, payloadBuilders(svc, cmds[i].CommandData, data)) } return files } func buildSubcommandData(sd *ServiceData, e *EndpointData) *subcommandData { flags, buildFunction := buildFlags(sd, e) + if buildFunction != nil { + buildFunction.Name = e.CLIPayloadDeclaration.Name() + } sub := &subcommandData{ SubcommandData: cli.BuildSubcommandData(sd.Service, e.Method, buildFunction, flags), + methodName: e.Method.Name, } if e.MultipartRequestEncoder != nil { sub.MultipartVarName = e.MultipartRequestEncoder.VarName - sub.MultipartFuncName = e.MultipartRequestEncoder.FuncName + sub.MultipartFuncDeclaration = e.MultipartRequestEncoder.FuncDeclaration + sub.MultipartFuncName = e.MultipartRequestEncoder.FuncDeclaration.Name() } if e.Method.SkipRequestBodyEncodeDecode { - sub.StreamFlag = streamFlag(sd.Service.Name, e.Method.Name) - sub.BuildStreamPayload = e.BuildStreamPayload + sub.StreamFlag = flags[len(flags)-1] + sub.BuildStreamPayloadDeclaration = e.BuildStreamPayloadDeclaration + sub.BuildStreamPayload = e.BuildStreamPayloadDeclaration.Name() } return sub } // endpointParser returns the file that implements the command line parser that // builds the client endpoint and payload necessary to perform a request. -func endpointParser(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, data []*commandData, services *ServicesData) *codegen.File { +func endpointParser(root *expr.RootExpr, svr *expr.ServerExpr, data []*commandData, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() pkg := codegen.SnakeCase(codegen.Goify(svr.Name, true)) path := filepath.Join(codegen.Gendir, services.dir(), "cli", pkg, "cli.go") + outputPackage := generatedFileOutputPackage(services, path) title := fmt.Sprintf("%s %s client CLI support package", svr.Name, services.label()) specs := []*codegen.ImportSpec{ {Path: "encoding/json"}, @@ -128,44 +169,122 @@ func endpointParser(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, da if sd == nil { continue } - specs = append(specs, &codegen.ImportSpec{ - Path: genpkg + "/" + services.dir() + "/" + sd.Service.PathName + "/client", - Name: sd.Service.PkgName + "c", - }) + clientImport := services.PackageImport( + outputPackage, + genpkg+"/"+services.dir()+"/"+sd.Service.PathName+"/client", + ) + specs = append(specs, clientImport) // Add interceptors import if service has client interceptors if len(sd.Service.ClientInterceptors) > 0 { - specs = append(specs, &codegen.ImportSpec{ - Path: genpkg + "/" + sd.Service.PathName, - Name: sd.Service.PkgName, - }) + specs = append(specs, services.ServiceImport(outputPackage, svc.Name)) } } + parser := services.cliParsers[svr.Name] + if parser == nil { + panic(fmt.Sprintf("HTTP CLI parser names are missing for server %q", svr.Name)) + } + plannedData := make([]*commandData, len(data)) cliData := make([]*cli.CommandData, len(data)) - for i, cmd := range data { - cliData[i] = cmd.CommandData + var parserLocals []*cli.ParserLocalData + for i, command := range data { + commandNames := parser.Commands[command.serviceName] + if commandNames == nil { + panic(fmt.Sprintf("HTTP CLI command names are missing for service %q", command.serviceName)) + } + commandCopy := *command + commonCommand := *command.CommandData + clientImport := services.PackageImport( + outputPackage, + genpkg+"/"+services.dir()+"/"+services.Get(command.serviceName).Service.PathName+"/client", + ) + commonCommand.PkgName = clientImport.Name + if commonCommand.Interceptors != nil { + interceptors := *commonCommand.Interceptors + interceptors.PkgName = services.ServiceImport(outputPackage, command.serviceName).Name + commonCommand.Interceptors = &interceptors + } + commonCommand.UsageDeclaration = commandNames.Usage + commandCopy.CommandData = &commonCommand + if commandCopy.NeedDialer { + suffix := "Configurer" + use := "websocket configurer" + if commandCopy.JSONRPC { + suffix = "ConfigFn" + use = "JSON-RPC connection configurer" + } + commandCopy.ConfigurerLocal = &cli.ParserLocalData{ + ServiceName: command.serviceName, + Use: use, + PreferredName: commonCommand.VarName + suffix, + } + parserLocals = append(parserLocals, commandCopy.ConfigurerLocal) + } + commandCopy.Subcommands = make([]*subcommandData, len(command.Subcommands)) + commonCommand.Subcommands = make([]*cli.SubcommandData, len(command.Subcommands)) + for j, subcommand := range command.Subcommands { + usage := commandNames.Methods[subcommand.methodName] + if usage == nil { + panic(fmt.Sprintf("HTTP CLI method help name is missing for %q.%q", command.serviceName, subcommand.methodName)) + } + subcommandCopy := *subcommand + commonSubcommand := *subcommand.SubcommandData + if commonSubcommand.Interceptors != nil { + interceptors := *commonSubcommand.Interceptors + interceptors.PkgName = services.ServiceImport(outputPackage, command.serviceName).Name + commonSubcommand.Interceptors = &interceptors + } + commonSubcommand.UsageDeclaration = usage + subcommandCopy.SubcommandData = &commonSubcommand + if subcommandCopy.MultipartVarName != "" { + subcommandCopy.MultipartLocal = &cli.ParserLocalData{ + ServiceName: command.serviceName, + MethodName: subcommand.methodName, + Use: "multipart request encoder", + PreferredName: subcommandCopy.MultipartVarName, + } + parserLocals = append(parserLocals, subcommandCopy.MultipartLocal) + } + commandCopy.Subcommands[j] = &subcommandCopy + commonCommand.Subcommands[j] = &commonSubcommand + } + plannedData[i] = &commandCopy + cliData[i] = &commonCommand + } + parser.PlanVariables(cliData, parserLocals) + for _, command := range plannedData { + for _, subcommand := range command.Subcommands { + if subcommand.StreamFlag != nil { + subcommand.StreamPointerVar = subcommand.StreamFlag.PointerVar + } + } } parseSection := &codegen.SectionTemplate{ Name: "parse-endpoint", Source: httpTemplates.Read(parseEndpointT), Data: struct { - FlagsCode string - Commands []*commandData + Declaration *codegen.NameDeclaration + FlagsCode string + Commands []*commandData + Variables *cli.ParserVariablesData }{ - cli.FlagsCode(cliData), - data, + parser.Declarations.ParseEndpoint, + parser.FlagsCode(cliData), + plannedData, + parser.Variables, }, FuncMap: map[string]any{"streamingCmdExists": streamingCmdExists}, } - return cli.EndpointParserFile(path, title, specs, cliData, parseSection) + return parser.EndpointParserFile(path, title, specs, cliData, parseSection) } // payloadBuilders returns the file that contains the payload constructors that // use flag values as arguments. -func payloadBuilders(genpkg string, svc *expr.HTTPServiceExpr, data *cli.CommandData, services *ServicesData) *codegen.File { +func payloadBuilders(svc *expr.HTTPServiceExpr, data *cli.CommandData, services *ServicesData) *codegen.File { sd := services.Get(svc.Name()) path := filepath.Join(codegen.Gendir, services.dir(), sd.Service.PathName, "client", "cli.go") + outputPackage := generatedFileOutputPackage(services, path) title := fmt.Sprintf("%s %s client CLI support package", svc.Name(), services.label()) specs := []*codegen.ImportSpec{ {Path: "encoding/json"}, @@ -176,9 +295,9 @@ func payloadBuilders(genpkg string, svc *expr.HTTPServiceExpr, data *cli.Command {Path: "unicode/utf8"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + sd.Service.PathName, Name: sd.Service.PkgName}, + services.ServiceImport(outputPackage, svc.Name()), } - return cli.PayloadBuildersFile(path, title, specs, data) + return addPlannedFileImports(cli.PayloadBuildersFile(path, title, specs, data), services) } // buildFlags builds the flag data and build function for an endpoint. @@ -195,7 +314,7 @@ func buildFlags(svc *ServiceData, e *EndpointData) ([]*cli.FlagData, *cli.BuildF args = append(args, e.Payload.Request.PayloadInit.CLIArgs...) flags, buildFunction = makeFlags(e, args, e.Payload.Request.PayloadType) } else if e.Payload.Ref != "" { - flags = append(flags, cli.NewFlagData(svcn, en, "p", e.Method.PayloadRef, e.Method.PayloadDesc, true, e.Method.PayloadEx, e.Method.PayloadDefault)) + flags = append(flags, cli.NewFlagDataForPlan(svcn, en, "p", e.Payload.CLIPlan, e.Method.PayloadDesc, true, e.Method.PayloadEx, e.Method.PayloadDefault)) } if e.Method.SkipRequestBodyEncodeDecode { flags = append(flags, streamFlag(svcn, en)) @@ -220,13 +339,13 @@ func makeFlags(e *EndpointData, args []*InitArgData, payload expr.DataType) ([]* fargs[i] = &cli.FlagArgData{ Name: arg.VarName, TypeName: arg.TypeName, + Plan: arg.CLIPlan, TypeRef: arg.TypeRef, FieldName: arg.FieldName, Description: arg.Description, Required: arg.Required, Example: arg.Example, DefaultValue: arg.DefaultValue, - Validate: arg.Validate, OmitField: arg.FieldName == "" && arg.VarName != "body", } } @@ -247,7 +366,8 @@ func makeFlags(e *EndpointData, args []*InitArgData, payload expr.DataType) ([]* // streamFlag returns the flag used to specify the upload file for endpoints // that use SkipRequestBodyEncodeDecode. func streamFlag(svcn, en string) *cli.FlagData { - return cli.NewFlagData(svcn, en, "stream", "string", "path to file containing the streamed request body", true, "goa.png", nil) + plan := cli.NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", nil) + return cli.NewFlagDataForPlan(svcn, en, "stream", plan, "path to file containing the streamed request body", true, "goa.png", nil) } // streamingCmdExists returns true if at least one command in the list of commands diff --git a/http/codegen/client_cli_test.go b/http/codegen/client_cli_test.go index a9aebff5f0..c70964c30b 100644 --- a/http/codegen/client_cli_test.go +++ b/http/codegen/client_cli_test.go @@ -1,12 +1,16 @@ +// This file verifies HTTP client CLI generation consumes stable, non-empty +// examples for body, parameter, header, cookie, array, and map flags. package codegen import ( "testing" - "goa.design/goa/v3/codegen/testutil" - "goa.design/goa/v3/expr" + "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" ) @@ -53,11 +57,72 @@ func TestClientCLIFiles(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientCLIFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientCLIFiles() sections := fs[c.FileIndex].SectionTemplates code := codegen.SectionCode(t, sections[c.SectionIndex]) testutil.AssertGo(t, "testdata/golden/client_cli_"+c.Name+".go.golden", code) }) } } + +// TestClientCLIBuildNameMatchesDeclaration verifies released plugins can read +// the final payload builder name without choosing that name themselves. +func TestClientCLIBuildNameMatchesDeclaration(t *testing.T) { + root := expr.RunDSL(t, testdata.MultiSimpleDSL) + plan := linkedHTTPPlanForRoot(t, root) + files := plan.ClientCLIFiles() + require.Greater(t, len(files), 1) + + build, ok := files[1].SectionTemplates[1].Data.(*cli.BuildFunctionData) + require.True(t, ok) + endpoint := plan.services.Get("ServiceMultiSimple1").Endpoint("MethodMultiSimplePayload") + require.Equal(t, endpoint.CLIPayloadDeclaration.Name(), build.Name) +} + +// TestClientCLITransportNamesMatchDeclarations checks the released multipart +// and stream helper names exposed to plugin templates. +func TestClientCLITransportNamesMatchDeclarations(t *testing.T) { + plan := linkedHTTPPlanForRoot(t, releasedHTTPNamesRoot(t)) + service := plan.services.Get("Names") + + multipart := buildSubcommandData(service, service.Endpoint("Multipart")) + require.NotNil(t, multipart.MultipartFuncDeclaration) + require.Equal(t, multipart.MultipartFuncDeclaration.Name(), multipart.MultipartFuncName) + + stream := buildSubcommandData(service, service.Endpoint("Raw")) + require.NotNil(t, stream.BuildStreamPayloadDeclaration) + require.Equal(t, stream.BuildStreamPayloadDeclaration.Name(), stream.BuildStreamPayload) +} + +func TestEmptyBodyCLIUsesPayloadFieldExample(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadBodyPrimitiveFieldEmptyDSL) + plan := linkedHTTPPlanForRoot(t, root) + endpoint := plan.services.Get("ServiceBodyPrimitiveArrayUser").Endpoints[0] + require.NotNil(t, endpoint.Payload.Request.PayloadInit) + require.Len(t, endpoint.Payload.Request.PayloadInit.ClientArgs, 1) + example := endpoint.Payload.Request.PayloadInit.ClientArgs[0].Example + + require.IsType(t, []string{}, example) + require.NotEmpty(t, example) +} + +// TestClientCLINestedBodyWithoutValidationEmitsNoChecks verifies that a body +// with no top-level checks does not add validation to the payload builder. +func TestClientCLINestedBodyWithoutValidationEmitsNoChecks(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadBodyUserInnerDSL) + plan := linkedHTTPPlanForRoot(t, root) + files := plan.ClientCLIFiles() + require.NotEmpty(t, files) + service := plan.services.Get("ServiceBodyUserInner") + endpoint := service.Endpoints[0] + require.NotNil(t, endpoint.Payload.Request.PayloadInit) + require.Len(t, endpoint.Payload.Request.PayloadInit.ClientArgs, 1) + arg := endpoint.Payload.Request.PayloadInit.ClientArgs[0] + require.Empty(t, arg.Validate) + require.NotNil(t, arg.CLIPlan) + _, builder := buildFlags(service, endpoint) + require.NotNil(t, builder) + require.Len(t, builder.Fields, 1) + require.NotContains(t, builder.Fields[0].Init, "goa.") +} diff --git a/http/codegen/client_decode_test.go b/http/codegen/client_decode_test.go index b66a8b7d9a..954a9cf501 100644 --- a/http/codegen/client_decode_test.go +++ b/http/codegen/client_decode_test.go @@ -34,16 +34,24 @@ func TestClientDecode(t *testing.T) { {"with-headers-dsl-viewed-result", testdata.WithHeadersBlockViewedResultDSL}, {"validate-error-response-type", testdata.ValidateErrorResponseTypeDSL}, {"empty-error-response-body", testdata.EmptyErrorResponseBodyDSL}, + {"required-primitive-arrays", testdata.RequiredPrimitiveArrayDSL}, + {"skip-response-body-encode-decode", testdata.ServerSkipResponseBodyEncodeDecodeDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates - require.Greater(t, len(sections), 2) - code := codegen.SectionCode(t, sections[2]) + var section *codegen.SectionTemplate + for _, s := range sections { + if s.Name == "response-decoder" { + section = s + } + } + require.NotNil(t, section) + code := codegen.SectionCode(t, section) testutil.AssertGo(t, "testdata/golden/client_decode_"+c.Name+".go.golden", code) }) } diff --git a/http/codegen/client_encode_test.go b/http/codegen/client_encode_test.go index 3a2c43a908..453c50f036 100644 --- a/http/codegen/client_encode_test.go +++ b/http/codegen/client_encode_test.go @@ -177,12 +177,13 @@ func TestClientEncode(t *testing.T) { {"query-custom-name", testdata.PayloadQueryCustomNameDSL}, {"header-custom-name", testdata.PayloadHeaderCustomNameDSL}, {"cookie-custom-name", testdata.PayloadCookieCustomNameDSL}, + {"skip-request-body-header", testdata.SkipRequestBodyEncodeDecodeHeaderDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) @@ -192,6 +193,25 @@ func TestClientEncode(t *testing.T) { } } +// TestSkipRequestBodyEncoderSelection verifies raw body access keeps encoders +// needed for headers while omitting an encoder that would do no work. +func TestSkipRequestBodyEncoderSelection(t *testing.T) { + t.Run("mapped header", func(t *testing.T) { + root := expr.RunDSL(t, testdata.SkipRequestBodyEncodeDecodeHeaderDSL) + plan := linkedHTTPPlanForRoot(t, root) + service, ok := plan.Service(root.API.HTTP.Service("SkipRequestBodyEncodeDecodeHeader")) + require.True(t, ok) + require.NotNil(t, service.Endpoints[0].RequestEncoderDeclaration) + }) + t.Run("raw body only", func(t *testing.T) { + root := expr.RunDSL(t, testdata.SkipRequestBodyEncodeDecodeDSL) + plan := linkedHTTPPlanForRoot(t, root) + service, ok := plan.Service(root.API.HTTP.Service("SkipRequestBodyEncodeDecode")) + require.True(t, ok) + require.Nil(t, service.Endpoints[0].RequestEncoderDeclaration) + }) +} + func TestClientBuildRequest(t *testing.T) { cases := []struct { Name string @@ -205,8 +225,8 @@ func TestClientBuildRequest(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) diff --git a/http/codegen/client_init_test.go b/http/codegen/client_init_test.go index d19ce7e272..dbeaed1b4c 100644 --- a/http/codegen/client_init_test.go +++ b/http/codegen/client_init_test.go @@ -25,8 +25,8 @@ func TestClientInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, c.FileCount) sections := fs[0].SectionTemplates require.Greater(t, len(sections), c.SectionNum) diff --git a/http/codegen/client_query_float_runtime_test.go b/http/codegen/client_query_float_runtime_test.go new file mode 100644 index 0000000000..37b05c2649 --- /dev/null +++ b/http/codegen/client_query_float_runtime_test.go @@ -0,0 +1,143 @@ +// This file runs generated HTTP client query encoders and checks the exact +// values that a server receives after URL parsing. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedClientFormatsFloatQueriesCompactly catches fixed-point query +// formatting that expands values which have a shorter exponent form. +func TestGeneratedClientFormatsFloatQueriesCompactly(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("float_query", func() { + dsl.Method("format", func() { + dsl.Payload(func() { + dsl.Attribute("scalar32", dsl.Float32) + dsl.Attribute("scalar64", dsl.Float64) + dsl.Attribute("repeated32", dsl.ArrayOf(dsl.Float32)) + dsl.Attribute("repeated64", dsl.ArrayOf(dsl.Float64)) + dsl.Required("scalar32", "scalar64", "repeated32", "repeated64") + }) + dsl.HTTP(func() { + dsl.GET("/") + dsl.Param("scalar32") + dsl.Param("scalar64") + dsl.Param("repeated32") + dsl.Param("repeated64") + }) + }) + }) + }) + + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedFloatQueryTest(t, files) +} + +// runGeneratedFloatQueryTest writes the generated packages and a test in the +// generated client package, then runs that test in an isolated module. +func runGeneratedFloatQueryTest(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + goaRoot := floatQueryModuleDirectory(t) + module := "module generated.local\n\ngo 1.24\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaRoot) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + testPath := filepath.Join(directory, "gen", "http", "float_query", "client", "float_query_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(generatedFloatQueryTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/float_query/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated float query test:\n%s", output) +} + +// floatQueryModuleDirectory returns this Goa checkout so the temporary module +// tests the generated code against the same runtime as the generator. +func floatQueryModuleDirectory(t *testing.T) string { + t.Helper() + command := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "goa.design/goa/v3") + output, err := command.CombinedOutput() + require.NoError(t, err, "resolve Goa module:\n%s", output) + directory := strings.TrimSpace(string(output)) + require.NotEmpty(t, directory) + return directory +} + +const generatedFloatQueryTest = `package client + +import ( + "net/http" + "testing" + + genfloatquery "generated.local/gen/float_query" +) + +func TestFloatQueryValues(t *testing.T) { + payload := &genfloatquery.FormatPayload{ + Scalar32: 12.5, + Scalar64: 1e100, + Repeated32: []float32{1e20, 0.25}, + Repeated64: []float64{1e100, 0.25}, + } + request, err := http.NewRequest(http.MethodGet, "http://example.com", nil) + if err != nil { + t.Fatal(err) + } + if err := EncodeFormatRequest(nil)(request, payload); err != nil { + t.Fatal(err) + } + query := request.URL.Query() + if got := query.Get("scalar32"); got != "12.5" { + t.Fatalf("scalar32 query = %q, want %q", got, "12.5") + } + if got := query.Get("scalar64"); got != "1e+100" { + t.Fatalf("scalar64 query = %q, want %q", got, "1e+100") + } + if got := query["repeated32"]; len(got) != 2 || got[0] != "1e+20" || got[1] != "0.25" { + t.Fatalf("repeated32 query = %#v, want %#v", got, []string{"1e+20", "0.25"}) + } + if got := query["repeated64"]; len(got) != 2 || got[0] != "1e+100" || got[1] != "0.25" { + t.Fatalf("repeated64 query = %#v, want %#v", got, []string{"1e+100", "0.25"}) + } +} +` diff --git a/http/codegen/client_response_body_runtime_test.go b/http/codegen/client_response_body_runtime_test.go new file mode 100644 index 0000000000..31bdb92821 --- /dev/null +++ b/http/codegen/client_response_body_runtime_test.go @@ -0,0 +1,313 @@ +// This file renders an HTTP client into a temporary module and calls its +// generated endpoints and response decoders. The response bodies can fail +// while reading or closing so the tests can check every returned error. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedClientResponseBodyLifecycle checks that generated decoders +// close bodies they consume, preserve bodies requested by callers, and return +// every read, decode, and close error. +func TestGeneratedClientResponseBodyLifecycle(t *testing.T) { + root := expr.RunDSL(t, responseBodyLifecycleDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + clientFiles := httpPlans[0].ClientFiles() + endpointCode := codegen.SectionsCode(t, clientFiles[0].Section("client-endpoint-init")) + testutil.AssertGo(t, "testdata/golden/client_endpoint_response_body_lifecycle.go.golden", endpointCode) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, clientFiles...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedResponseBodyLifecycleTest(t, files) +} + +// responseBodyLifecycleDSL defines an ordinary response, a response whose +// bytes are returned to the caller, and a server-sent event stream. +func responseBodyLifecycleDSL() { + dsl.Service("body_lifecycle", func() { + dsl.Method("read", func() { + dsl.Result(func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.HTTP(func() { + dsl.GET("/read") + }) + }) + dsl.Method("raw", func() { + dsl.Error("bad", func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + dsl.HTTP(func() { + dsl.GET("/raw") + dsl.SkipResponseBodyEncodeDecode() + dsl.Response(dsl.StatusOK) + dsl.Response("bad", dsl.StatusBadRequest) + }) + }) + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} + +// runGeneratedResponseBodyLifecycleTest writes generated code and its runtime +// test into an isolated module, then runs only the generated client package. +func runGeneratedResponseBodyLifecycleTest(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + testPath := filepath.Join(directory, "gen", "http", "body_lifecycle", "client", "response_body_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(generatedResponseBodyLifecycleTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/body_lifecycle/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated response body test:\n%s", output) +} + +const generatedResponseBodyLifecycleTest = `package client + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (doer doerFunc) Do(request *http.Request) (*http.Response, error) { + return doer(request) +} + +type controlledBody struct { + reader io.Reader + readErr error + closeErr error + closeCalls int +} + +func (body *controlledBody) Read(buffer []byte) (int, error) { + if body.readErr != nil { + return 0, body.readErr + } + return body.reader.Read(buffer) +} + +func (body *controlledBody) Close() error { + body.closeCalls++ + return body.closeErr +} + +func TestRestoreBodyReturnsReadFailureAndClosesOriginal(t *testing.T) { + readErr := errors.New("read failed") + body := &controlledBody{reader: strings.NewReader("ignored"), readErr: readErr} + response := response(http.StatusOK, body) + + _, err := DecodeReadResponse(goahttp.ResponseDecoder, true)(response) + + assertDecodingError(t, err) + require.ErrorIs(t, err, readErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestDecoderReturnsCloseFailureAfterSuccess(t *testing.T) { + closeErr := errors.New("close failed") + body := &controlledBody{reader: strings.NewReader(` + "`" + `{"value":"ready"}` + "`" + `), closeErr: closeErr} + response := response(http.StatusOK, body) + + result, err := DecodeReadResponse(goahttp.ResponseDecoder, false)(response) + + require.NotNil(t, result) + assertDecodingError(t, err) + require.ErrorIs(t, err, closeErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestDecoderReturnsDecodeAndCloseFailures(t *testing.T) { + decodeErr := errors.New("decode failed") + closeErr := errors.New("close failed") + body := &controlledBody{reader: strings.NewReader("ignored"), closeErr: closeErr} + response := response(http.StatusOK, body) + decoder := func(*http.Response) goahttp.Decoder { + return goahttp.EncodingFunc(func(any) error { + return decodeErr + }) + } + + _, err := DecodeReadResponse(decoder, false)(response) + + assertDecodingError(t, err) + require.ErrorIs(t, err, decodeErr) + require.ErrorIs(t, err, closeErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestRestoreBodyLeavesReadableCopyAndClosesOriginal(t *testing.T) { + const encoded = ` + "`" + `{"value":"ready"}` + "`" + ` + body := &controlledBody{reader: strings.NewReader(encoded)} + response := response(http.StatusOK, body) + + result, err := DecodeReadResponse(goahttp.ResponseDecoder, true)(response) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, body.closeCalls) + restored, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, encoded, string(restored)) +} + +func TestUnexpectedStatusReturnsReadFailure(t *testing.T) { + readErr := errors.New("read failed") + body := &controlledBody{reader: strings.NewReader("ignored"), readErr: readErr} + response := response(http.StatusTeapot, body) + + _, err := DecodeReadResponse(goahttp.ResponseDecoder, false)(response) + + assertDecodingError(t, err) + require.ErrorIs(t, err, readErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestRawBodyRemainsCallerOwned(t *testing.T) { + for _, restoreBody := range []bool{false, true} { + t.Run(fmt.Sprintf("restoreBody=%t", restoreBody), func(t *testing.T) { + body := &controlledBody{reader: strings.NewReader("raw bytes")} + response := response(http.StatusOK, body) + + _, err := DecodeRawResponse(goahttp.ResponseDecoder, restoreBody)(response) + + require.NoError(t, err) + require.Same(t, body, response.Body) + require.Zero(t, body.closeCalls) + content, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, "raw bytes", string(content)) + }) + } +} + +func TestStreamContentTypeReturnsCloseFailure(t *testing.T) { + closeErr := errors.New("close failed") + body := &controlledBody{reader: strings.NewReader("ignored"), closeErr: closeErr} + doer := doerFunc(func(*http.Request) (*http.Response, error) { + response := response(http.StatusOK, body) + response.Header.Set("Content-Type", "application/json") + return response, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.ErrorContains(t, err, "unexpected content type") + require.ErrorIs(t, err, closeErr) + assertDecodingError(t, err) + require.Equal(t, 1, body.closeCalls) +} + +func TestStreamContentTypeRemainsPlainWhenCloseSucceeds(t *testing.T) { + body := &controlledBody{reader: strings.NewReader("ignored")} + doer := doerFunc(func(*http.Request) (*http.Response, error) { + response := response(http.StatusOK, body) + response.Header.Set("Content-Type", "application/json") + return response, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.EqualError(t, err, "unexpected content type: application/json (expected text/event-stream)") + var clientErr *goahttp.ClientError + require.NotErrorAs(t, err, &clientErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestRawEndpointReturnsDecoderAndCloseFailures(t *testing.T) { + decodeErr := errors.New("decode failed") + closeErr := errors.New("close failed") + body := &controlledBody{reader: strings.NewReader("ignored"), closeErr: closeErr} + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return response(http.StatusBadRequest, body), nil + }) + decoder := func(*http.Response) goahttp.Decoder { + return goahttp.EncodingFunc(func(any) error { + return decodeErr + }) + } + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, decoder, false) + + _, err := client.Raw()(context.Background(), nil) + + require.ErrorIs(t, err, decodeErr) + require.ErrorIs(t, err, closeErr) + assertDecodingError(t, err) + require.Equal(t, 1, body.closeCalls) +} + +func response(status int, body io.ReadCloser) *http.Response { + return &http.Response{StatusCode: status, Header: make(http.Header), Body: body} +} + +func assertDecodingError(t *testing.T, err error) { + t.Helper() + var clientErr *goahttp.ClientError + require.ErrorAs(t, err, &clientErr) + require.Equal(t, "decoding_error", clientErr.Name) +} +` diff --git a/http/codegen/clone.go b/http/codegen/clone.go new file mode 100644 index 0000000000..06846a127a --- /dev/null +++ b/http/codegen/clone.go @@ -0,0 +1,129 @@ +// This file copies the values used to write generated files. A caller may +// change the copy without changing the HTTP files saved for the same service. +package codegen + +import ( + "fmt" + "reflect" + + "goa.design/goa/v3/codegen" +) + +var immutableRenderPointers = map[reflect.Type]struct{}{ + reflect.TypeFor[*codegen.NameDeclaration](): {}, + reflect.TypeFor[*codegen.TypeDeclaration](): {}, + reflect.TypeFor[*codegen.UnionDeclaration](): {}, + reflect.TypeFor[*codegen.UnionBranchDeclaration](): {}, + reflect.TypeFor[*codegen.Location](): {}, + reflect.TypeFor[*codegen.GoTypePlan](): {}, + reflect.TypeFor[*wireTypeRecord](): {}, + reflect.TypeFor[*wireUnionRecord](): {}, +} + +// cloneRenderData copies maps, slices, pointers, and values stored in an +// interface. Generated name and type records are shared because they cannot be +// changed after their names are assigned. +func cloneRenderData(data any) any { + if data == nil { + return nil + } + return cloneRenderValue(reflect.ValueOf(data), make(map[clonePointer]reflect.Value)).Interface() +} + +type clonePointer struct { + typeOf reflect.Type + pointer uintptr +} + +// cloneRenderValue remembers pointers it has already copied. This preserves +// repeated references and lets it copy values that refer back to themselves. +func cloneRenderValue(source reflect.Value, seen map[clonePointer]reflect.Value) reflect.Value { + if !source.IsValid() { + return source + } + if source.Type() == reflect.TypeFor[TypeData]() { + data := source.Interface().(TypeData) + copy := data + copy.Init = copyInitData(data.Init) + copy.Example = cloneRenderData(data.Example) + return reflect.ValueOf(copy) + } + switch source.Kind() { + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, + reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, + reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, reflect.String: + return source + case reflect.Func: + panic(fmt.Sprintf("HTTP template data contains function value of type %s", source.Type())) + case reflect.Interface: + if source.IsNil() { + return reflect.Zero(source.Type()) + } + copy := reflect.New(source.Type()).Elem() + copy.Set(cloneRenderValue(source.Elem(), seen)) + return copy + case reflect.Pointer: + if source.IsNil() { + return reflect.Zero(source.Type()) + } + if _, ok := immutableRenderPointers[source.Type()]; ok { + return source + } + key := clonePointer{source.Type(), uintptr(source.UnsafePointer())} + if copy, ok := seen[key]; ok { + return copy + } + copy := reflect.New(source.Type().Elem()) + seen[key] = copy + copy.Elem().Set(cloneRenderValue(source.Elem(), seen)) + return copy + case reflect.Slice: + if source.IsNil() { + return reflect.Zero(source.Type()) + } + key := clonePointer{source.Type(), source.Pointer()} + if copy, ok := seen[key]; ok { + return copy + } + copy := reflect.MakeSlice(source.Type(), source.Len(), source.Len()) + seen[key] = copy + for index := 0; index < source.Len(); index++ { + copy.Index(index).Set(cloneRenderValue(source.Index(index), seen)) + } + return copy + case reflect.Map: + if source.IsNil() { + return reflect.Zero(source.Type()) + } + key := clonePointer{source.Type(), uintptr(source.UnsafePointer())} + if copy, ok := seen[key]; ok { + return copy + } + copy := reflect.MakeMapWithSize(source.Type(), source.Len()) + seen[key] = copy + iterator := source.MapRange() + for iterator.Next() { + copy.SetMapIndex(cloneRenderValue(iterator.Key(), seen), cloneRenderValue(iterator.Value(), seen)) + } + return copy + case reflect.Struct: + copy := reflect.New(source.Type()).Elem() + for index := 0; index < source.NumField(); index++ { + field := source.Type().Field(index) + if field.PkgPath != "" { + panic(fmt.Sprintf("HTTP template data contains private field %s.%s", source.Type(), field.Name)) + } + copy.Field(index).Set(cloneRenderValue(source.Field(index), seen)) + } + return copy + case reflect.Array: + copy := reflect.New(source.Type()).Elem() + for index := 0; index < source.Len(); index++ { + copy.Index(index).Set(cloneRenderValue(source.Index(index), seen)) + } + return copy + default: + panic(fmt.Sprintf("HTTP template data contains unsupported %s value", source.Kind())) + } +} diff --git a/http/codegen/compatibility.go b/http/codegen/compatibility.go new file mode 100644 index 0000000000..7f26895443 --- /dev/null +++ b/http/codegen/compatibility.go @@ -0,0 +1,72 @@ +// This file keeps released HTTP generator entry points available to plugins +// while all rendering uses the one transport plan retained by Goa. +package codegen + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// ClientFiles returns the planned client files. genpkg must match the package +// used to create data. +func ClientFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return clientFiles(data) +} + +// ServerFiles returns the planned server files. genpkg must match the package +// used to create data. +func ServerFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return serverFiles(data) +} + +// ServerTypeFiles returns the planned server type files. genpkg must match the +// package used to create data. +func ServerTypeFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return serverTypeFiles(data) +} + +// ClientTypeFiles returns the planned client type files. genpkg must match the +// package used to create data. +func ClientTypeFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return clientTypeFiles(data) +} + +// PathFiles returns the planned request path files. +func PathFiles(data *ServicesData) []*codegen.File { + return pathFiles(data) +} + +// ClientEncodeDecodeFile returns the planned client encoder and decoder file +// for service. genpkg must match the package used to create data. +func ClientEncodeDecodeFile(genpkg string, service *expr.HTTPServiceExpr, data *ServicesData) *codegen.File { + requireGeneratedPackage(genpkg, data) + return clientEncodeDecodeFile(service, data) +} + +// ServerEncodeDecodeFile returns the planned server encoder and decoder file +// for service. genpkg must match the package used to create data. +func ServerEncodeDecodeFile(genpkg string, service *expr.HTTPServiceExpr, data *ServicesData) *codegen.File { + requireGeneratedPackage(genpkg, data) + return serverEncodeDecodeFile(service, data) +} + +// WebsocketClientFile returns the planned WebSocket client file for service. +// genpkg must match the package used to create data. +func WebsocketClientFile(genpkg string, service *expr.HTTPServiceExpr, data *ServicesData) *codegen.File { + requireGeneratedPackage(genpkg, data) + return websocketClientFile(service, data) +} + +// requireGeneratedPackage rejects a package argument that does not describe +// the HTTP data supplied by the same generation run. +func requireGeneratedPackage(genpkg string, data *ServicesData) { + if genpkg != data.GenPkg() { + panic(fmt.Sprintf("HTTP generation package %q does not match planned package %q", genpkg, data.GenPkg())) + } +} diff --git a/http/codegen/cookie_security_test.go b/http/codegen/cookie_security_test.go index 91f75fd194..91ec5906eb 100644 --- a/http/codegen/cookie_security_test.go +++ b/http/codegen/cookie_security_test.go @@ -1,3 +1,5 @@ +// This file renders HTTP security designs through both OpenAPI versions and +// verifies cookie API-key placement with run-owned example generation enabled. package codegen import ( @@ -36,7 +38,6 @@ func TestCookieAPIKeySecurity(t *testing.T) { t.Run("openapi uses cookie security scheme", func(t *testing.T) { root := expr.RunDSL(t, cookieAPIKeySecurityDSL) - openapi.Definitions = make(map[string]*openapi.Schema) v2Files, err := openapiv2.Files(root, openapi.DefaultPath20) require.NoError(t, err) @@ -55,7 +56,6 @@ func TestCookieAPIKeySecurity(t *testing.T) { require.Contains(t, (*swagger.Paths["/auth/profile"].Get.Security)[0], name) } - openapi.Definitions = make(map[string]*openapi.Schema) v3JSON := renderOpenAPIJSON(t, openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30)) loader := openapi3.NewLoader() doc, err := loader.LoadFromData(v3JSON) @@ -76,9 +76,9 @@ func TestCookieAPIKeySecurity(t *testing.T) { t.Run("http codegen does not duplicate cookie-backed auth fields", func(t *testing.T) { root := expr.RunDSL(t, cookieAPIKeySecurityDSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) - serverTypes := typesFile("gen", root.API.HTTP.Services[0], true, services) + serverTypes := plan.ServerTypeFiles()[0] var serverTypesBuf bytes.Buffer for _, section := range serverTypes.SectionTemplates[1:] { require.NoError(t, section.Write(&serverTypesBuf)) @@ -88,7 +88,7 @@ func TestCookieAPIKeySecurity(t *testing.T) { require.NotContains(t, serverTypesCode, "browserSession *string, browserSession *string") require.NotContains(t, serverTypesCode, "browserSession string, browserSession string") - serverFiles := ServerFiles("", services) + serverFiles := plan.ServerFiles() require.Len(t, serverFiles, 2) serverDecode := codegen.SectionCode(t, serverFiles[1].SectionTemplates[2]) require.Contains(t, serverDecode, `r.Cookie("__Host-ak_session")`) @@ -96,7 +96,7 @@ func TestCookieAPIKeySecurity(t *testing.T) { require.NotContains(t, serverDecode, "browserSession *string, browserSession *string") require.NotContains(t, serverDecode, "browserSession string, browserSession string") - clientFiles := ClientFiles("", services) + clientFiles := plan.ClientFiles() require.Len(t, clientFiles, 2) clientEncode := codegen.SectionCode(t, clientFiles[1].SectionTemplates[2]) require.Contains(t, clientEncode, `req.AddCookie(&http.Cookie{`) diff --git a/http/codegen/error_body_description_test.go b/http/codegen/error_body_description_test.go new file mode 100644 index 0000000000..8b63bce767 --- /dev/null +++ b/http/codegen/error_body_description_test.go @@ -0,0 +1,44 @@ +// This file verifies generated HTTP error body comments name the service +// errors that use each body type. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestErrorBodyDescriptionNamesSingleError(t *testing.T) { + root := expr.RunDSL(t, testdata.WithErrorCustomPkgDSL) + plan := linkedHTTPPlanForRoot(t, root) + want := "MethodWithErrorCustomPkgErrorNameResponseBody is the type of the\n" + + "// \"ServiceWithErrorCustomPkg\" service \"MethodWithErrorCustomPkg\" endpoint HTTP\n" + + "// response body for the \"error_name\" error." + + for _, file := range []struct { + name string + sections string + }{ + {name: "client", sections: renderHTTPSections(t, plan.ClientTypeFiles()[0])}, + {name: "server", sections: renderHTTPSections(t, plan.ServerTypeFiles()[0])}, + } { + t.Run(file.name, func(t *testing.T) { + require.Contains(t, file.sections, want) + }) + } +} + +// renderHTTPSections writes all generated sections after the file header. +func renderHTTPSections(t *testing.T, file *codegen.File) string { + t.Helper() + var rendered strings.Builder + for _, section := range file.SectionTemplates[1:] { + require.NoError(t, section.Write(&rendered)) + } + return rendered.String() +} diff --git a/http/codegen/example_cli.go b/http/codegen/example_cli.go index 3d0ba64ec2..90a69f51fb 100644 --- a/http/codegen/example_cli.go +++ b/http/codegen/example_cli.go @@ -1,44 +1,51 @@ +// This file writes runnable HTTP and JSON-RPC command-line examples with the +// package names already chosen for this generation. package codegen import ( - "os" + "path" "path/filepath" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) -// ExampleCLIFiles returns an example client tool implementation for the -// transport described by services for each server expression. -func ExampleCLIFiles(genpkg string, services *ServicesData) []*codegen.File { +// exampleCLIFiles returns an example command-line client for the HTTP services +// on each configured server. +func exampleCLIFiles(root *example.Root, services *ServicesData) []*codegen.File { var files []*codegen.File - for _, svr := range services.Root.API.Servers { - if f := ExampleCLI(genpkg, svr, services); f != nil { + for _, server := range root.Servers { + if f := exampleCLI(root, server, services); f != nil { files = append(files, f) } } return files } -// ExampleCLI returns an example client tool implementation for the transport -// described by services and the given server expression. -func ExampleCLI(genpkg string, svr *expr.ServerExpr, services *ServicesData) *codegen.File { - svrdata := example.Servers.Get(svr, services.Root) - path := filepath.Join("cmd", svrdata.Dir+"-cli", services.dir()+".go") - if _, err := os.Stat(path); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } +// exampleCLI returns an example command-line client for the HTTP services on +// the given server. +func exampleCLI(root *example.Root, server *example.Data, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() + outputPath := filepath.Join("cmd", server.Dir+"-cli", services.dir()+".go") + outputPackage := path.Join(path.Dir(genpkg), "cmd", server.Dir+"-cli") funcSuffix := "HTTP" if services.jsonrpc { funcSuffix = "JSONRPC" } - rootPath := example.RootPath(genpkg) + rootPath := path.Dir(genpkg) + cliImport := services.PackageImport(outputPackage, path.Join(genpkg, services.dir(), "cli", server.Dir)) + parser := services.cliParsers[server.Name] + if parser == nil { + panic("HTTP command parser names are missing for server " + server.Name) + } specs := []*codegen.ImportSpec{ {Path: "context"}, - {Path: "encoding/json"}, + {Path: "errors"}, {Path: "flag"}, {Path: "fmt"}, + {Path: "io"}, {Path: "net/http"}, {Path: "net/url"}, {Path: "os"}, @@ -47,23 +54,29 @@ func ExampleCLI(genpkg string, svr *expr.ServerExpr, services *ServicesData) *co {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + services.dir() + "/cli/" + svrdata.Dir, Name: "cli"}, + cliImport, + } + hasClientInterceptors := false + for _, name := range root.Services { + data := services.ServicesData.Get(name) + serviceImport := services.ServiceImport(outputPackage, name) + specs = append(specs, serviceImport) + hasClientInterceptors = hasClientInterceptors || len(data.ClientInterceptors) > 0 } - importScope := codegen.NewNameScope() - for _, svc := range services.Root.Services { - data := services.ServicesData.Get(svc.Name) - specs = append(specs, &codegen.ImportSpec{Path: genpkg + "/" + data.PkgName}) - importScope.Unique(data.PkgName) + var interceptorsPkg string + if hasClientInterceptors { + interceptorImport := services.PackageImport(outputPackage, rootPath+"/interceptors") + interceptorsPkg = interceptorImport.Name + specs = append(specs, interceptorImport) } - interceptorsPkg := importScope.Unique("interceptors", "ex") - specs = append(specs, &codegen.ImportSpec{Path: rootPath + "/interceptors", Name: interceptorsPkg}) - apiPkg := example.APIPkg(services.Root, importScope) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: apiPkg}) + apiImport := services.PackageImport(outputPackage, rootPath) + apiPkg := apiImport.Name + specs = append(specs, apiImport) var svcData []*ServiceData - for _, svc := range svr.Services { + for _, svc := range server.Services { if data := services.Get(svc); data != nil { - svcData = append(svcData, data) + svcData = append(svcData, exampleServiceDataForOutput(data, services, outputPackage)) } } sections := []*codegen.SectionTemplate{ @@ -91,12 +104,22 @@ func ExampleCLI(genpkg string, svr *expr.ServerExpr, services *ServicesData) *co Name: "cli-http-end", Source: httpTemplates.Read(cliEndT), Data: map[string]any{ - "Services": svcData, - "APIPkg": apiPkg, + "Services": svcData, + "APIPkg": apiPkg, + "CLIPkg": cliImport.Name, + "Parser": parser.Declarations, + "Transport": services.label(), }, FuncMap: map[string]any{ - "needDialer": NeedDialer, - "hasWebSocket": HasWebSocket, + "hasAnyInputStreams": cliHasAnyInputStreams, + "hasInputStreams": cliHasInputStreams, + "hasRunnable": cliHasRunnableCommands, + "hasRunnableService": cliHasRunnableService, + "needDialer": NeedDialer, + "hasWebSocket": HasWebSocket, + "kebab": codegen.KebabCase, + "streamsInput": cliStreamsInput, + "streamsOutput": cliStreamsOutput, }, }, { @@ -104,12 +127,70 @@ func ExampleCLI(genpkg string, svr *expr.ServerExpr, services *ServicesData) *co Source: httpTemplates.Read(cliUsageT), Data: map[string]any{ "VarPrefix": services.dir(), + "CLIPkg": cliImport.Name, + "Parser": parser.Declarations, }, }, } return &codegen.File{ - Path: path, + Path: outputPath, SectionTemplates: sections, SkipExist: true, } } + +// cliStreamsInput reports whether an example command would need to send more +// payload values after the endpoint call starts. +func cliStreamsInput(method *service.MethodData) bool { + return method.StreamKind == expr.ClientStreamKind || method.StreamKind == expr.BidirectionalStreamKind +} + +// cliStreamsOutput reports whether an example command receives a sequence of +// results from the server. +func cliStreamsOutput(method *service.MethodData) bool { + return method.StreamKind == expr.ServerStreamKind +} + +// cliHasInputStreams reports whether a service has commands that the example +// client must reject before parsing an endpoint. +func cliHasInputStreams(data *ServiceData) bool { + for _, endpoint := range data.Endpoints { + if cliStreamsInput(endpoint.Method) { + return true + } + } + return false +} + +// cliHasAnyInputStreams reports whether any service has a command that the +// example client must reject before parsing an endpoint. +func cliHasAnyInputStreams(services []*ServiceData) bool { + for _, data := range services { + if cliHasInputStreams(data) { + return true + } + } + return false +} + +// cliHasRunnableCommands reports whether the example client can invoke at +// least one generated endpoint. +func cliHasRunnableCommands(services []*ServiceData) bool { + for _, data := range services { + if cliHasRunnableService(data) { + return true + } + } + return false +} + +// cliHasRunnableService reports whether the example client can invoke at +// least one endpoint in the service. +func cliHasRunnableService(data *ServiceData) bool { + for _, endpoint := range data.Endpoints { + if !cliStreamsInput(endpoint.Method) { + return true + } + } + return false +} diff --git a/http/codegen/example_cli_test.go b/http/codegen/example_cli_test.go index 032358dfd8..dc171c8bd4 100644 --- a/http/codegen/example_cli_test.go +++ b/http/codegen/example_cli_test.go @@ -1,3 +1,4 @@ +// This file verifies generated HTTP command-line client examples. package codegen import ( @@ -8,9 +9,7 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/http/codegen/testdata" ) @@ -25,14 +24,14 @@ func TestExampleCLIFiles(t *testing.T) { {"server-hosting-multiple-services", ctestdata.ServerHostingMultipleServicesDSL}, {"streaming", testdata.StreamingResultDSL}, {"streaming-multiple-services", testdata.StreamingMultipleServicesDSL}, + {"streaming-input-only", testdata.StreamingPayloadDSL}, + {"mixed-results", testdata.MixedResultsDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - httpServices := NewServicesData(service.NewServicesData(root), root.API.HTTP) - fs := ExampleCLIFiles("", httpServices) + examples := linkedHTTPExamplePlanForRoot(t, root) + fs := examples.CLIFiles() require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -45,3 +44,22 @@ func TestExampleCLIFiles(t *testing.T) { }) } } + +func TestExampleCLIUsesServicePathsForCommands(t *testing.T) { + root := codegen.RunDSL(t, collidingServiceNamesDSL) + examples := linkedHTTPExamplePlanForRoot(t, root) + files := examples.CLIFiles() + require.Len(t, files, 1) + + var output bytes.Buffer + for _, section := range files[0].SectionTemplates { + require.NoError(t, section.Write(&output)) + } + first := examples.transport.services.Get("read_value").Service + second := examples.transport.services.Get("read-value").Service + firstCommand := codegen.KebabCase(first.PathName) + secondCommand := codegen.KebabCase(second.PathName) + require.NotEqual(t, firstCommand, secondCommand) + require.Contains(t, output.String(), `case "`+firstCommand+`":`) + require.Contains(t, output.String(), `case "`+secondCommand+`":`) +} diff --git a/http/codegen/example_server.go b/http/codegen/example_server.go index 6fb3144c79..b4f2bf5c35 100644 --- a/http/codegen/example_server.go +++ b/http/codegen/example_server.go @@ -1,37 +1,63 @@ +// This file writes runnable HTTP servers and file-upload helpers with the +// package names already chosen for this generation. package codegen import ( - "os" + "maps" "path" "path/filepath" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/expr" ) -// ExampleServerFiles returns an example http service implementation. -func ExampleServerFiles(genpkg string, data *ServicesData) []*codegen.File { +type ( + // exampleServerArgumentData contains one typed parameter accepted by a + // generated transport helper. + exampleServerArgumentData struct { + // Name is the parameter name used inside the helper. + Name string + // PkgName is the generated service package name. + PkgName string + // TypeName is the generated service or endpoint type name. + TypeName string + // Pointer is true when TypeName is an endpoint collection pointer. + Pointer bool + } + + // exampleMultipartDecoderData describes the HTTP request body filled by one + // starter multipart decoder. + exampleMultipartDecoderData struct { + *MultipartData + // BodyType is the request body type as seen from the starter service + // package. + BodyType string + } +) + +// exampleServerFiles builds each runnable HTTP server from copied server data. +func exampleServerFiles(root *example.Root, data *ServicesData) []*codegen.File { var fw []*codegen.File - for _, svr := range data.Root.API.Servers { - if m := ExampleServer(genpkg, data.Root, svr, data); m != nil { + for _, server := range root.Servers { + if m := exampleServer(server, data); m != nil { fw = append(fw, m) } } for _, svc := range data.Expressions.Services { - if f := dummyMultipartFile(genpkg, data.Root, svc, data); f != nil { + if f := dummyMultipartFile(svc, data); f != nil { fw = append(fw, f) } } return fw } -// ExampleServer returns an example HTTP server implementation. -func ExampleServer(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, services *ServicesData) *codegen.File { - svrdata := example.Servers.Get(svr, root) - fpath := filepath.Join("cmd", svrdata.Dir, "http.go") - specs := make([]*codegen.ImportSpec, 0, 12+2*len(root.API.HTTP.Services)) +// exampleServer returns an example HTTP server implementation. +func exampleServer(server *example.Data, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() + fpath := filepath.Join("cmd", server.Dir, "http.go") + outputPackage := path.Join(path.Dir(genpkg), "cmd", server.Dir) + specs := make([]*codegen.ImportSpec, 0, 12+2*len(services.Expressions.Services)) baseSpecs := []*codegen.ImportSpec{ {Path: "context"}, {Path: "net/http"}, @@ -47,29 +73,31 @@ func ExampleServer(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, ser } specs = append(specs, baseSpecs...) - scope := codegen.NewNameScope() - for _, svc := range root.API.HTTP.Services { - sd := services.Get(svc.Name()) + for _, serviceName := range server.Services { + sd := services.Get(serviceName) + if sd == nil { + continue + } svcName := sd.Service.PathName - specs = append(specs, - &codegen.ImportSpec{ - Path: path.Join(genpkg, "http", svcName, "server"), - Name: scope.Unique(sd.Service.PkgName + "svr"), - }, - &codegen.ImportSpec{ - Path: path.Join(genpkg, svcName), - Name: scope.Unique(sd.Service.PkgName), - }) + serverImport := services.PackageImport(outputPackage, path.Join(genpkg, "http", svcName, "server")) + serviceImport := services.ServiceImport(outputPackage, serviceName) + specs = append(specs, serverImport, serviceImport) } - rootPath := example.RootPath(genpkg) - apiPkg := scope.Unique(strings.ToLower(codegen.Goify(services.Root.API.Name, false) + "api")) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: apiPkg}) + rootPath := path.Dir(genpkg) + apiImport := services.PackageImport(outputPackage, rootPath) + apiPkg := apiImport.Name + specs = append(specs, apiImport) var svcdata []*ServiceData - for _, svc := range svr.Services { + for _, svc := range server.Services { if data := services.Get(svc); data != nil { - svcdata = append(svcdata, data) + copy := exampleServiceDataForOutput(data, services, outputPackage) + copy.ServerPkgName = services.PackageImport( + outputPackage, + path.Join(genpkg, "http", data.Service.PathName, "server"), + ).Name + svcdata = append(svcdata, copy) } } @@ -79,7 +107,8 @@ func ExampleServer(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, ser Name: "server-http-start", Source: httpTemplates.Read(serverStartT), Data: map[string]any{ - "Services": svcdata, + "Services": svcdata, + "HandlerArgs": exampleServerArguments(server, svcdata, nil), // JSONRPCServices must always be set (typed nil when // absent) so the template functions receive a valid // []*ServiceData value. The JSON-RPC generator @@ -126,40 +155,256 @@ func ExampleServer(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, ser return &codegen.File{Path: fpath, SectionTemplates: sections, SkipExist: true} } -// dummyMultipartFile returns a dummy implementation of the multipart decoders -// and encoders. -func dummyMultipartFile(genpkg string, root *expr.RootExpr, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { - mpath := "multipart.go" - if _, err := os.Stat(mpath); !os.IsNotExist(err) { - return nil // file already exists, skip it. +// combinedExampleServerFiles builds runnable server files that mount both the +// JSON-RPC services and the ordinary HTTP services from one design. The caller +// may edit every returned file without changing either input plan. +func combinedExampleServerFiles(root *example.Root, jsonrpc, application *ServicesData) []*codegen.File { + files := make([]*codegen.File, 0, len(root.Servers)) + for _, server := range root.Servers { + file := combinedExampleServer(server, jsonrpc, application) + if file != nil { + files = append(files, file) + } } - var ( - sections []*codegen.SectionTemplate - mustGen bool + if application != nil { + for _, service := range application.Expressions.Services { + if file := dummyMultipartFile(service, application); file != nil { + files = append(files, cloneGeneratedFile(file)) + } + } + } + return files +} - scope = codegen.NewNameScope() - ) - // determine the unique API package name different from the service names - for _, httpSvc := range root.API.HTTP.Services { - s := services.Get(httpSvc.Name()) - if s == nil { - panic("unknown http service, " + httpSvc.Name()) // bug +// combinedExampleServer builds one main-package file for a configured server. +// It reads service membership from server and writes separate HTTP and +// JSON-RPC lists because the code that writes main initializes them differently. +func combinedExampleServer(server *example.Data, jsonrpc, application *ServicesData) *codegen.File { + outputPackage := path.Join(path.Dir(jsonrpc.GenPkg()), "cmd", server.Dir) + imports := []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "net/http"}, + {Path: "net/url"}, + {Path: "os"}, + {Path: "sync"}, + {Path: "time"}, + codegen.GoaNamedImport("http", "goahttp"), + {Path: "goa.design/clue/debug"}, + {Path: "goa.design/clue/log"}, + codegen.GoaImport("middleware"), + {Path: "github.com/gorilla/websocket"}, + } + var ordinaryServices []*ServiceData + if application != nil { + for _, name := range server.Services { + data := application.Get(name) + if data == nil { + continue + } + copy := exampleServiceDataForOutput(data, application, outputPackage) + copy.ServerPkgName = application.PackageImport( + outputPackage, + path.Join(application.GenPkg(), "http", data.Service.PathName, "server"), + ).Name + ordinaryServices = append(ordinaryServices, copy) + imports = append(imports, + application.PackageImport(outputPackage, path.Join(application.GenPkg(), "http", data.Service.PathName, "server")), + application.ServiceImport(outputPackage, name), + ) } - if s.Service == nil { - panic("unknown service, " + httpSvc.Name()) // bug + } + var jsonrpcServices []*ServiceData + for _, name := range server.Services { + data := jsonrpc.Get(name) + if data == nil { + continue } - scope.Unique(s.Service.PkgName) + copy := exampleServiceDataForOutput(data, jsonrpc, outputPackage) + copy.ServerPkgName = jsonrpc.PackageImport( + outputPackage, + path.Join(jsonrpc.GenPkg(), "jsonrpc", data.Service.PathName, "server"), + ).Name + jsonrpcServices = append(jsonrpcServices, copy) + imports = append(imports, + jsonrpc.PackageImport(outputPackage, path.Join(jsonrpc.GenPkg(), "jsonrpc", data.Service.PathName, "server")), + jsonrpc.ServiceImport(outputPackage, name), + ) + } + if len(ordinaryServices) == 0 && len(jsonrpcServices) == 0 { + return nil + } + apiImport := jsonrpc.PackageImport(outputPackage, path.Dir(jsonrpc.GenPkg())) + imports = append(imports, apiImport) + imports = uniqueExampleImports(imports) + data := map[string]any{ + "Services": ordinaryServices, + "JSONRPCServices": jsonrpcServices, + "HandlerArgs": exampleServerArguments(server, ordinaryServices, jsonrpcServices), } + sections := []*codegen.SectionTemplate{ + codegen.Header("", "main", imports), + {Name: "server-http-start", Source: httpTemplates.Read(serverStartT), Data: data}, + {Name: "server-http-encoding", Source: httpTemplates.Read(serverEncodingT)}, + {Name: "server-http-mux", Source: httpTemplates.Read(serverMuxT)}, + { + Name: "server-http-init", + Source: httpTemplates.Read(serverConfigureT), + Data: map[string]any{ + "Services": ordinaryServices, + "JSONRPCServices": jsonrpcServices, + "APIPkg": apiImport.Name, + }, + FuncMap: map[string]any{"needDialer": NeedDialer, "hasWebSocket": HasWebSocket}, + }, + {Name: "server-http-middleware", Source: httpTemplates.Read(serverMiddlewareT)}, + {Name: "server-http-end", Source: httpTemplates.Read(serverEndT), Data: data}, + {Name: "server-http-errorhandler", Source: httpTemplates.Read(serverErrorHandlerT)}, + } + return &codegen.File{ + Path: filepath.Join("cmd", server.Dir, "http.go"), + SectionTemplates: sections, + SkipExist: true, + } +} + +// exampleServerArguments adds generated Go names and types to the ordered +// service values copied by the shared example plan. +func exampleServerArguments( + server *example.Data, + ordinary, jsonrpc []*ServiceData, +) []*exampleServerArgumentData { + services := make(map[string]*ServiceData, len(ordinary)+len(jsonrpc)) + for _, service := range ordinary { + services[service.Service.Name] = service + } + for _, service := range jsonrpc { + services[service.Service.Name] = service + } + planned := server.HandlerArgs(example.TransportHTTP) + arguments := make([]*exampleServerArgumentData, len(planned)) + for index, argument := range planned { + service := services[argument.Service].Service + data := &exampleServerArgumentData{ + PkgName: service.PkgName, + } + if argument.Endpoint { + data.Name = service.VarName + "Endpoints" + data.TypeName = service.EndpointsDeclaration.Name() + data.Pointer = true + } else { + data.Name = service.VarName + "Svc" + data.TypeName = service.ServiceDeclaration.Name() + } + arguments[index] = data + } + return arguments +} + +// uniqueExampleImports keeps the first import for each Go package path. A +// service exposed over both protocols uses the same generated service package. +func uniqueExampleImports(imports []*codegen.ImportSpec) []*codegen.ImportSpec { + result := make([]*codegen.ImportSpec, 0, len(imports)) + seen := make(map[string]struct{}, len(imports)) + for _, spec := range imports { + if _, ok := seen[spec.Path]; ok { + continue + } + seen[spec.Path] = struct{}{} + result = append(result, spec) + } + return result +} + +// cloneGeneratedFile copies a generated file and its section records so the +// caller may change the copy without changing the source plan. +func cloneGeneratedFile(source *codegen.File) *codegen.File { + if source == nil { + return nil + } + clone := *source + clone.SectionTemplates = make([]*codegen.SectionTemplate, len(source.SectionTemplates)) + for index, section := range source.SectionTemplates { + sectionClone := *section + sectionClone.FuncMap = maps.Clone(section.FuncMap) + sectionClone.Data = cloneRenderData(section.Data) + clone.SectionTemplates[index] = §ionClone + } + return &clone +} + +// cloneJSONRPCCodecFile copies an encoder and decoder file and replaces each +// HTTP endpoint value with the smaller value read by JSON-RPC code. +func cloneJSONRPCCodecFile(source *codegen.File) *codegen.File { + if source == nil { + return nil + } + clone := *source + clone.SectionTemplates = make([]*codegen.SectionTemplate, len(source.SectionTemplates)) + for index, section := range source.SectionTemplates { + sectionCopy := *section + sectionCopy.FuncMap = maps.Clone(section.FuncMap) + if endpoint, ok := section.Data.(*EndpointData); ok { + switch section.Name { + case "response-decoder": + data := copyJSONRPCEndpoint(endpoint) + sectionCopy.Data = &data + case "request-builder", "request-encoder", "request-decoder": + sectionCopy.Data = copyJSONRPCRequestCodec(endpoint) + default: + panic("JSON-RPC codec contains an unsupported endpoint section " + section.Name) + } + } else if helper, ok := section.Data.(*codegen.TransformFunctionData); ok { + if section.Name != "client-transform-helper" && section.Name != "server-transform-helper" { + panic("JSON-RPC codec contains a transform helper in unsupported section " + section.Name) + } + sectionCopy.Data = copyJSONRPCTransformFunction(helper) + } else { + sectionCopy.Data = cloneRenderData(section.Data) + } + clone.SectionTemplates[index] = §ionCopy + } + return &clone +} + +// dummyMultipartFile returns a dummy implementation of the multipart decoders +// and encoders. +func dummyMultipartFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() + mpath := "multipart.go" + outputPackage := path.Dir(genpkg) + var ( + sections []*codegen.SectionTemplate + decoderData = make(map[*MultipartData]*exampleMultipartDecoderData) + mustGen bool + ) { specs := make([]*codegen.ImportSpec, 0, 2) specs = append(specs, &codegen.ImportSpec{Path: "mime/multipart"}) data := services.Get(svc.Name()) - specs = append(specs, &codegen.ImportSpec{ - Path: path.Join(genpkg, data.Service.PathName), - Name: scope.Unique(data.Service.PkgName, "svc"), - }) + var multipartEndpoints []*expr.HTTPEndpointExpr + for _, endpoint := range data.Endpoints { + if endpoint.MultipartRequestDecoder != nil || endpoint.MultipartRequestEncoder != nil { + multipartEndpoints = append(multipartEndpoints, svc.Endpoint(endpoint.Method.Name)) + } + } + specs = append(specs, services.ServiceImport(outputPackage, svc.Name())) + rootPath := path.Dir(genpkg) + specs = append(specs, services.AttributeImports(rootPath, serviceReferenceAttributes(multipartEndpoints...)...)...) + for _, endpoint := range data.Endpoints { + if endpoint.MultipartRequestDecoder == nil { + continue + } + bodyType, bodyImport := exampleMultipartBodyType(svc, endpoint, services, outputPackage) + if bodyImport != nil { + specs = append(specs, bodyImport) + } + decoderData[endpoint.MultipartRequestDecoder] = &exampleMultipartDecoderData{ + MultipartData: endpoint.MultipartRequestDecoder, + BodyType: bodyType, + } + } - apiPkg := example.APIPkg(root, scope) + apiPkg := examplePackageImportName(services.Root) sections = []*codegen.SectionTemplate{codegen.Header("", apiPkg, specs)} for _, e := range data.Endpoints { if e.MultipartRequestDecoder != nil { @@ -167,7 +412,7 @@ func dummyMultipartFile(genpkg string, root *expr.RootExpr, svc *expr.HTTPServic sections = append(sections, &codegen.SectionTemplate{ Name: "dummy-multipart-request-decoder", Source: httpTemplates.Read(dummyMultipartRequestDecoderT), - Data: e.MultipartRequestDecoder, + Data: decoderData[e.MultipartRequestDecoder], }) } if e.MultipartRequestEncoder != nil { @@ -189,3 +434,31 @@ func dummyMultipartFile(genpkg string, root *expr.RootExpr, svc *expr.HTTPServic SkipExist: true, } } + +// exampleMultipartBodyType returns the request body type visible from the +// starter service package and the generated server import needed to name it. +func exampleMultipartBodyType(svc *expr.HTTPServiceExpr, endpoint *EndpointData, services *ServicesData, outputPackage string) (string, *codegen.ImportSpec) { + serverBody := endpoint.Payload.Request.ServerBody + service := services.Get(svc.Name()) + serverPath := path.Join(services.GenPkg(), "http", service.Service.PathName, "server") + serverImport := services.PackageImport(outputPackage, serverPath) + if serverBody.Declaration != nil { + return serverImport.Name + "." + serverBody.Declaration.Name(), serverImport + } + body := serverBody.attribute + usesServerType := false + collectUserTypes(body.Type, func(expr.UserType) { + usesServerType = true + }) + if usesServerType { + resolver := &wireAttributeScope{ + catalog: service.serverWireTypes, + base: codegen.NewAttributeScope(service.serverWireTypes.scope), + pkg: serverImport.Name, + policy: jsonBodyPolicy(true, true, true, ""), + exactOccurrence: true, + } + return resolver.Name(body, serverImport.Name, true, false), serverImport + } + return serverBody.VarName, nil +} diff --git a/http/codegen/example_server_test.go b/http/codegen/example_server_test.go index d28d4d73f5..8c2f1c97e4 100644 --- a/http/codegen/example_server_test.go +++ b/http/codegen/example_server_test.go @@ -1,3 +1,4 @@ +// This file verifies generated HTTP server examples. package codegen import ( @@ -9,10 +10,9 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" + dsl "goa.design/goa/v3/dsl" "goa.design/goa/v3/http/codegen/testdata" ) @@ -31,12 +31,10 @@ func TestExampleServerFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) require.Len(t, root.Services, 3) - httpServices := NewServicesData(service.NewServicesData(root), root.API.HTTP) - fs := ExampleServerFiles("", httpServices) + examples := linkedHTTPExamplePlanForRoot(t, root) + fs := examples.ServerFiles() require.Len(t, fs, 2) for i, f := range fs { if i < len(fs)-1 { @@ -55,6 +53,38 @@ func TestExampleServerFiles(t *testing.T) { } }) + t.Run("multipart code check", func(t *testing.T) { + cases := []struct { + Name string + DSL func() + }{ + {"object", testdata.PayloadMultipartValidationDSL}, + {"array", testdata.PayloadMultipartArrayTypeDSL}, + {"map", testdata.PayloadMultipartMapTypeDSL}, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + root := codegen.RunDSL(t, c.DSL) + examples := linkedHTTPExamplePlanForRoot(t, root) + var multipartFile *codegen.File + for _, file := range examples.ServerFiles() { + if file.Path == "multipart.go" { + multipartFile = file + break + } + } + require.NotNil(t, multipartFile) + var buf bytes.Buffer + for _, section := range multipartFile.SectionTemplates { + require.NoError(t, section.Write(&buf)) + } + code := codegen.FormatTestCode(t, buf.String()) + golden := filepath.Join("testdata", "golden", "server-multipart-"+c.Name+".golden") + testutil.CompareOrUpdateGolden(t, code, golden) + }) + } + }) + t.Run("code check", func(t *testing.T) { cases := []struct { Name string @@ -68,11 +98,9 @@ func TestExampleServerFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - httpServices := NewServicesData(service.NewServicesData(root), root.API.HTTP) - fs := ExampleServerFiles("", httpServices) + examples := linkedHTTPExamplePlanForRoot(t, root) + fs := examples.ServerFiles() require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -86,3 +114,44 @@ func TestExampleServerFiles(t *testing.T) { } }) } + +func TestExampleServerUsesServicePathsForLocalNames(t *testing.T) { + root := codegen.RunDSL(t, collidingServiceNamesDSL) + examples := linkedHTTPExamplePlanForRoot(t, root) + files := examples.ServerFiles() + require.Len(t, files, 1) + + var output bytes.Buffer + for _, section := range files[0].SectionTemplates { + require.NoError(t, section.Write(&output)) + } + first := examples.transport.services.Get("read_value").Service + second := examples.transport.services.Get("read-value").Service + firstBase := codegen.Goify(first.PathName, false) + secondBase := codegen.Goify(second.PathName, false) + require.NotEqual(t, firstBase, secondBase) + require.Contains(t, output.String(), firstBase+"Endpoints") + require.Contains(t, output.String(), secondBase+"Endpoints") + require.Contains(t, output.String(), firstBase+"Server") + require.Contains(t, output.String(), secondBase+"Server") +} + +// collidingServiceNamesDSL defines two services whose names become the same Go +// name. Their generated package paths remain distinct. +func collidingServiceNamesDSL() { + dsl.API("collision", func() { + dsl.Server("collision", func() { + dsl.Services("read_value", "read-value") + }) + }) + dsl.Service("read_value", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { dsl.GET("/underscore") }) + }) + }) + dsl.Service("read-value", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { dsl.GET("/dash") }) + }) + }) +} diff --git a/http/codegen/handler_test.go b/http/codegen/handler_test.go index d61cc85664..f7d79025e9 100644 --- a/http/codegen/handler_test.go +++ b/http/codegen/handler_test.go @@ -14,7 +14,6 @@ import ( ) func TestHandlerInit(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -31,8 +30,8 @@ func TestHandlerInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() sections := codegentest.Sections(fs, "server.go", "server-handler-init") require.Greater(t, len(sections), 0) code := codegen.SectionCode(t, sections[0]) diff --git a/http/codegen/idempotency_test.go b/http/codegen/idempotency_test.go index e859cb2e86..17b2335d75 100644 --- a/http/codegen/idempotency_test.go +++ b/http/codegen/idempotency_test.go @@ -1,3 +1,5 @@ +// This file verifies repeated HTTP generation produces the same Go names and +// does not keep changeable values from an earlier run. package codegen import ( @@ -37,8 +39,8 @@ func TestIdempotentHTTPEndpointCodegen(t *testing.T) { }) }) }) - services := CreateHTTPServices(root) - clientFiles := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + clientFiles := plan.ClientFiles() require.NotEmpty(t, clientFiles) clientCode := codegen.SectionsCode(t, clientFiles[0].Section("client-endpoint-init")) @@ -50,8 +52,8 @@ func TestIdempotentHTTPEndpointCodegen(t *testing.T) { // TestFileGenerationIdempotent builds the HTTP services data once and renders // the complete generated file set twice, asserting that both renders produce // byte-identical outputs. This guards against file generators mutating shared -// analysis state (e.g. the ServerTypeNames/ClientTypeNames dedup sets or the -// PathInit argument data) in ways that change subsequent renders. +// analysis state (for example package declaration catalogs or PathInit +// argument data) in ways that change subsequent renders. func TestFileGenerationIdempotent(t *testing.T) { cases := []struct { Name string @@ -64,14 +66,14 @@ func TestFileGenerationIdempotent(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) render := func(dir string) { - files := PathFiles(services) - files = append(files, ServerFiles("gen", services)...) - files = append(files, ClientFiles("gen", services)...) - files = append(files, ServerTypeFiles("gen", services)...) - files = append(files, ClientTypeFiles("gen", services)...) + files := plan.PathFiles() + files = append(files, plan.ServerFiles()...) + files = append(files, plan.ClientFiles()...) + files = append(files, plan.ServerTypeFiles()...) + files = append(files, plan.ClientTypeFiles()...) require.NotEmpty(t, files) for _, f := range files { _, err := f.Render(dir) diff --git a/http/codegen/jsonrpc_data.go b/http/codegen/jsonrpc_data.go new file mode 100644 index 0000000000..d9a89c04b7 --- /dev/null +++ b/http/codegen/jsonrpc_data.go @@ -0,0 +1,415 @@ +// This file copies the HTTP values used to write JSON-RPC files. Changing a +// copy cannot change the HTTP values saved for the same service. +package codegen + +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // jsonRPCRequestCodecData contains the values used to write JSON-RPC request + // builders, encoders, and decoders. + jsonRPCRequestCodecData struct { + *JSONRPCEndpointSnapshot + BasicScheme *service.SchemeData + HeaderSchemes service.SchemesData + MultipartRequestEncoder any + MultipartRequestDecoder any + } + + // jsonRPCTransformFunctionData contains the five values used to write one + // generated conversion function. + jsonRPCTransformFunctionData struct { + Declaration *codegen.NameDeclaration + Name string + ParamTypeRef string + ResultTypeRef string + Code string + } +) + +// copyJSONRPCEndpoint returns the values read by JSON-RPC files for endpoint. +// Changing the returned value cannot change endpoint. +func copyJSONRPCEndpoint(endpoint *EndpointData) JSONRPCEndpointSnapshot { + requestEncoder := "" + if endpoint.RequestEncoderDeclaration != nil { + requestEncoder = endpoint.RequestEncoderDeclaration.Name() + } + requestDecoder := "" + if endpoint.RequestDecoderDeclaration != nil { + requestDecoder = endpoint.RequestDecoderDeclaration.Name() + } + result := JSONRPCEndpointSnapshot{ + IsJSONRPC: endpoint.IsJSONRPC, + Method: copyJSONRPCMethod(endpoint.Method), + ServiceName: endpoint.ServiceName, + ServicePkgName: endpoint.ServicePkgName, + Payload: copyJSONRPCPayload(endpoint.Payload), + Result: copyJSONRPCResult(endpoint.Result), + Errors: copyJSONRPCErrors(endpoint.Errors), + Routes: copyJSONRPCRoutes(endpoint.Routes), + RequestInit: copyInitData(endpoint.RequestInit), + EndpointInit: endpoint.EndpointInit, + HandlerInit: endpoint.HandlerInitDeclaration.Name(), + HandlerInitDeclaration: endpoint.HandlerInitDeclaration, + ClientStruct: endpoint.ClientStructDeclaration.Name(), + ClientStructDeclaration: endpoint.ClientStructDeclaration, + RequestEncoder: requestEncoder, + RequestEncoderDeclaration: endpoint.RequestEncoderDeclaration, + RequestDecoder: requestDecoder, + RequestDecoderDeclaration: endpoint.RequestDecoderDeclaration, + ResponseDecoder: endpoint.ResponseDecoderDeclaration.Name(), + ResponseDecoderDeclaration: endpoint.ResponseDecoderDeclaration, + SSE: copyJSONRPCSSE(endpoint.SSE), + } + return result +} + +// copyJSONRPCRequestCodec returns the values used to write one JSON-RPC request. +// JSON-RPC request bodies cannot use multipart encoding. +func copyJSONRPCRequestCodec(endpoint *EndpointData) *jsonRPCRequestCodecData { + if endpoint.MultipartRequestEncoder != nil || endpoint.MultipartRequestDecoder != nil { + panic("JSON-RPC request codec cannot use multipart encoding") + } + data := copyJSONRPCEndpoint(endpoint) + return &jsonRPCRequestCodecData{ + JSONRPCEndpointSnapshot: &data, + BasicScheme: copyJSONRPCScheme(endpoint.BasicScheme), + HeaderSchemes: copyJSONRPCSchemes(endpoint.HeaderSchemes), + } +} + +// copyJSONRPCTransformFunction returns the values written into one generated +// conversion function. +func copyJSONRPCTransformFunction(helper *codegen.TransformFunctionData) *jsonRPCTransformFunctionData { + return &jsonRPCTransformFunctionData{ + Declaration: helper.Declaration, + Name: helper.Name, + ParamTypeRef: helper.ParamTypeRef, + ResultTypeRef: helper.ResultTypeRef, + Code: helper.Code, + } +} + +// copyJSONRPCSchemes returns new security records for a generated request. +func copyJSONRPCSchemes(schemes service.SchemesData) service.SchemesData { + result := make(service.SchemesData, len(schemes)) + for index, scheme := range schemes { + result[index] = copyJSONRPCScheme(scheme) + } + return result +} + +// copyJSONRPCScheme returns a security record that callers may change independently. +func copyJSONRPCScheme(scheme *service.SchemeData) *service.SchemeData { + if scheme == nil { + return nil + } + copy := *scheme + copy.Scopes = append([]string(nil), scheme.Scopes...) + copy.Flows = make([]*expr.FlowExpr, len(scheme.Flows)) + for index, flow := range scheme.Flows { + flowCopy := *flow + copy.Flows[index] = &flowCopy + } + return © +} + +// copyJSONRPCMethod returns the method names and stream methods used by JSON-RPC files. +func copyJSONRPCMethod(method *service.MethodData) JSONRPCMethodData { + result := JSONRPCMethodData{ + Name: method.Name, + VarName: method.VarName, + Result: method.Result, + HasMixedResults: method.HasMixedResults, + Idempotent: method.Idempotent, + ServerStream: copyJSONRPCStream(method.ServerStream), + ClientStream: copyJSONRPCStream(method.ClientStream), + StreamKind: method.StreamKind, + SkipRequestBodyEncodeDecode: method.SkipRequestBodyEncodeDecode, + RequestStruct: method.RequestStruct, + } + result.Errors = make([]JSONRPCMethodErrorData, len(method.Errors)) + for index, serviceError := range method.Errors { + result.Errors[index] = JSONRPCMethodErrorData{ + ErrName: serviceError.ErrName, + Temporary: serviceError.Temporary, + } + } + if method.ViewedResult != nil { + viewed := copyJSONRPCViewedResult(method.ViewedResult) + result.ViewedResult = &JSONRPCMethodViewedResultData{ + JSONRPCViewedResultData: viewed, + ViewName: method.ViewedResult.ViewName, + } + } + return result +} + +// copyJSONRPCViewedResult returns the names used to check and convert a viewed +// result in JSON-RPC files. +func copyJSONRPCViewedResult(viewed *service.ViewedResultTypeData) JSONRPCViewedResultData { + return JSONRPCViewedResultData{ + FullRef: viewed.FullRef, + VarName: viewed.VarName, + ViewsPkg: viewed.ViewsPkg, + Validate: viewed.Validate.Declaration, + ResultInit: viewed.ResultInit.Declaration, + Init: viewed.Init.Declaration, + IsCollection: viewed.IsCollection, + } +} + +// copyJSONRPCStream returns the service stream method names read by JSON-RPC files. +func copyJSONRPCStream(stream *service.StreamData) *JSONRPCStreamData { + if stream == nil { + return nil + } + return &JSONRPCStreamData{ + Interface: stream.Interface, + VarName: stream.VarName, + SendName: stream.SendName, + SendDesc: stream.SendDesc, + SendWithContextName: stream.SendWithContextName, + SendWithContextDesc: stream.SendWithContextDesc, + SendTypeName: stream.SendTypeName, + SendTypeRef: stream.SendTypeRef, + RecvName: stream.RecvName, + RecvDesc: stream.RecvDesc, + RecvWithContextName: stream.RecvWithContextName, + RecvWithContextDesc: stream.RecvWithContextDesc, + RecvTypeName: stream.RecvTypeName, + RecvTypeRef: stream.RecvTypeRef, + EndpointStruct: stream.EndpointStruct, + Kind: stream.Kind, + } +} + +// copyJSONRPCPayload returns the request values read by JSON-RPC files. +func copyJSONRPCPayload(payload *PayloadData) *JSONRPCPayloadData { + if payload == nil { + return nil + } + result := &JSONRPCPayloadData{ + Ref: payload.Ref, + IDAttribute: payload.IDAttribute, + IDAttributeRequired: payload.IDAttributeRequired, + DecoderReturnValue: payload.DecoderReturnValue, + } + if payload.Request != nil { + request := payload.Request + result.Request = &JSONRPCRequestData{ + ClientBody: copyJSONRPCBody(request.ClientBody), + ServerBody: copyJSONRPCBody(request.ServerBody), + PayloadInit: copyInitData(request.PayloadInit), + Headers: copyJSONRPCHeaders(request.Headers), + Cookies: copyJSONRPCCookies(request.Cookies), + PayloadAttr: request.PayloadAttr, + MustHaveBody: request.MustHaveBody, + MustValidate: request.MustValidate, + } + } + return result +} + +// copyJSONRPCResult returns the response values read by JSON-RPC files. +func copyJSONRPCResult(result *ResultData) *JSONRPCResultData { + if result == nil { + return nil + } + copy := &JSONRPCResultData{ + Ref: result.Ref, + IDAttribute: result.IDAttribute, + IDAttributeRequired: result.IDAttributeRequired, + View: result.View, + Responses: make([]JSONRPCResponseData, len(result.Responses)), + } + for index, response := range result.Responses { + copy.Responses[index] = copyJSONRPCResponse(response) + } + return copy +} + +// copyJSONRPCResponse returns the body, headers, cookies, and constructor for one response. +func copyJSONRPCResponse(response *ResponseData) JSONRPCResponseData { + serverBodies := make([]JSONRPCBodyData, len(response.ServerBody)) + for index, body := range response.ServerBody { + serverBodies[index] = *copyJSONRPCBody(body) + } + return JSONRPCResponseData{ + StatusCode: response.StatusCode, + Code: response.Code, + Headers: copyJSONRPCHeaders(response.Headers), + Cookies: copyJSONRPCCookies(response.Cookies), + ServerBody: serverBodies, + ClientBody: copyJSONRPCBody(response.ClientBody), + ResultInit: copyInitData(response.ResultInit), + MustValidate: response.MustValidate, + } +} + +// copyJSONRPCErrors returns the designed error responses read by JSON-RPC files. +func copyJSONRPCErrors(groups []*ErrorGroupData) []JSONRPCErrorGroupData { + result := make([]JSONRPCErrorGroupData, len(groups)) + for groupIndex, group := range groups { + errors := make([]JSONRPCErrorData, len(group.Errors)) + for errorIndex, serviceError := range group.Errors { + errors[errorIndex] = JSONRPCErrorData{ + Name: serviceError.Name, + Ref: serviceError.Ref, + Response: copyJSONRPCResponse(serviceError.Response), + } + } + result[groupIndex] = JSONRPCErrorGroupData{StatusCode: group.StatusCode, Errors: errors} + } + return result +} + +// copyJSONRPCRoutes returns the HTTP verbs and paths accepted by a JSON-RPC server. +func copyJSONRPCRoutes(routes []*RouteData) []JSONRPCRouteData { + result := make([]JSONRPCRouteData, len(routes)) + for index, route := range routes { + result[index] = JSONRPCRouteData{Verb: route.Verb, Path: route.Path} + } + return result +} + +// copyJSONRPCSSE returns the event-stream fields read by JSON-RPC files. +func copyJSONRPCSSE(stream *SSEData) *JSONRPCSSEData { + if stream == nil { + return nil + } + return &JSONRPCSSEData{ + StructDeclaration: stream.StructDeclaration, + ClientInterfaceDeclaration: stream.ClientInterfaceDeclaration, + ClientStructDeclaration: stream.ClientStructDeclaration, + ClientInitDeclaration: stream.ClientInitDeclaration, + EventTypeRef: stream.EventTypeRef, + HasResponseBody: stream.HasResponseBody, + Response: copyJSONRPCResponsePtr(stream.Response), + RequestIDField: stream.RequestIDField, + RequestIDPointer: stream.RequestIDPointer, + } +} + +// copyJSONRPCResponsePtr returns an independent copy of response. +func copyJSONRPCResponsePtr(response *ResponseData) *JSONRPCResponseData { + if response == nil { + return nil + } + copy := copyJSONRPCResponse(response) + return © +} + +// copyJSONRPCBody returns the generated body names and the code that converts +// the body value. +func copyJSONRPCBody(body *TypeData) *JSONRPCBodyData { + if body == nil { + return nil + } + return &JSONRPCBodyData{ + Declaration: body.Declaration, + VarName: body.VarName, + Ref: body.Ref, + ValidateRef: body.ValidateRef, + ValidatorDeclaration: body.ValidatorDeclaration, + ValidationTarget: body.ValidationTarget, + Init: copyInitData(body.Init), + } +} + +// copyInitData returns conversion arguments that callers may change without +// changing the source values. +func copyInitData(init *InitData) *InitData { + if init == nil { + return nil + } + copy := *init + copy.ServerArgs = copyInitArgs(init.ServerArgs) + copy.ClientArgs = copyInitArgs(init.ClientArgs) + copy.CLIArgs = copyInitArgs(init.CLIArgs) + return © +} + +// copyInitArgs returns conversion arguments with new attribute records. +func copyInitArgs(args []*InitArgData) []*InitArgData { + result := make([]*InitArgData, len(args)) + for index, arg := range args { + copy := *arg + if arg.AttributeData != nil { + attribute := *arg.AttributeData + attribute.Type = copyDataType(attribute.Type) + attribute.FieldType = copyDataType(attribute.FieldType) + attribute.DefaultValue = cloneRenderData(attribute.DefaultValue) + attribute.Example = cloneRenderData(attribute.Example) + copy.AttributeData = &attribute + } + result[index] = © + } + return result +} + +// copyDataType returns a new Goa type graph, or nil when no type was supplied. +func copyDataType(dataType expr.DataType) expr.DataType { + if dataType == nil { + return nil + } + return expr.Dup(dataType) +} + +// copyJSONRPCHeaders returns header values that do not share default data with source. +func copyJSONRPCHeaders(source []*HeaderData) []JSONRPCHeaderData { + result := make([]JSONRPCHeaderData, len(source)) + for index, header := range source { + result[index] = JSONRPCHeaderData{ + JSONRPCElementData: copyJSONRPCElement(header.Element), + CanonicalName: header.CanonicalName, + } + } + return result +} + +// copyJSONRPCCookies returns cookie values that do not share default data with source. +func copyJSONRPCCookies(source []*CookieData) []JSONRPCCookieData { + result := make([]JSONRPCCookieData, len(source)) + for index, cookie := range source { + result[index] = JSONRPCCookieData{ + JSONRPCElementData: copyJSONRPCElement(cookie.Element), + MaxAge: cookie.MaxAge, + Path: cookie.Path, + Domain: cookie.Domain, + Secure: cookie.Secure, + HTTPOnly: cookie.HTTPOnly, + SameSite: cookie.SameSite, + } + } + return result +} + +// copyJSONRPCElement returns the fields used to decode one header or cookie. +func copyJSONRPCElement(element *Element) JSONRPCElementData { + dataType := element.Type + result := JSONRPCElementData{ + Name: element.Name, + VarName: element.VarName, + TypeName: dataType.Name(), + ElemTypeRef: element.ElemTypeRef, + TypeRef: element.TypeRef, + Pointer: element.Pointer, + FieldName: element.FieldName, + FieldPointer: element.FieldPointer, + IsAliased: expr.IsAlias(element.FieldType), + Required: element.Required, + DefaultValue: cloneRenderData(element.DefaultValue), + Validate: element.Validate, + HTTPName: element.HTTPName, + StringSlice: element.StringSlice, + Slice: element.Slice, + } + if array := expr.AsArray(dataType); array != nil { + result.ElemTypeName = array.ElemType.Type.Name() + } + return result +} diff --git a/http/codegen/multipart_test.go b/http/codegen/multipart_test.go index 518e54fd2c..9ade50f6aa 100644 --- a/http/codegen/multipart_test.go +++ b/http/codegen/multipart_test.go @@ -13,21 +13,21 @@ import ( ) func TestServerMultipartFuncType(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() }{ {"multipart-body-primitive", testdata.PayloadMultipartPrimitiveDSL}, {"multipart-body-user-type", testdata.PayloadMultipartUserTypeDSL}, + {"multipart-body-validation", testdata.PayloadMultipartValidationDSL}, {"multipart-body-array-type", testdata.PayloadMultipartArrayTypeDSL}, {"multipart-body-map-type", testdata.PayloadMultipartMapTypeDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[0].SectionTemplates require.Greater(t, len(sections), 5) @@ -38,7 +38,6 @@ func TestServerMultipartFuncType(t *testing.T) { } func TestClientMultipartFuncType(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -51,8 +50,8 @@ func TestClientMultipartFuncType(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[0].SectionTemplates require.Greater(t, len(sections), 4) @@ -63,13 +62,13 @@ func TestClientMultipartFuncType(t *testing.T) { } func TestServerMultipartNewFunc(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() }{ {"server-multipart-body-primitive", testdata.PayloadMultipartPrimitiveDSL}, {"server-multipart-body-user-type", testdata.PayloadMultipartUserTypeDSL}, + {"server-multipart-body-validation", testdata.PayloadMultipartValidationDSL}, {"server-multipart-body-array-type", testdata.PayloadMultipartArrayTypeDSL}, {"server-multipart-body-map-type", testdata.PayloadMultipartMapTypeDSL}, {"server-multipart-with-param", testdata.PayloadMultipartWithParamDSL}, @@ -78,8 +77,8 @@ func TestServerMultipartNewFunc(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 3) @@ -90,7 +89,6 @@ func TestServerMultipartNewFunc(t *testing.T) { } func TestClientMultipartNewFunc(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -105,8 +103,8 @@ func TestClientMultipartNewFunc(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 3) diff --git a/http/codegen/oneof_http_codegen_test.go b/http/codegen/oneof_http_codegen_test.go index 365b45ca6e..8ff858576c 100644 --- a/http/codegen/oneof_http_codegen_test.go +++ b/http/codegen/oneof_http_codegen_test.go @@ -20,7 +20,7 @@ func TestClientCLIInlinesOneOfRequestValidation(t *testing.T) { require.Contains(t, code, "BuildMethodBodyUnionUserValidatePayload") require.Contains(t, code, "if body.A == nil") - require.Contains(t, code, "marshalUnionUserValidateRequestBodyTo") + require.Contains(t, code, "marshalUnionUserValidateRequestBodyToServicebodyunionuservalidateUnionUserValidate") require.NotContains(t, code, "ValidateMethodBodyUnionUserValidateRequestBody") } @@ -66,8 +66,8 @@ func renderClientCLISectionCode(t *testing.T, dsl func(), fileIndex, sectionInde t.Helper() root := expr.RunDSL(t, dsl) - services := CreateHTTPServices(root) - fs := ClientCLIFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientCLIFiles() return codegen.SectionCode(t, fs[fileIndex].SectionTemplates[sectionIndex]) } @@ -76,11 +76,9 @@ func renderClientCLISectionCode(t *testing.T, dsl func(), fileIndex, sectionInde func renderClientTypesCode(t *testing.T, dsl func()) string { t.Helper() - const genpkg = "gen" - root := expr.RunDSL(t, dsl) - services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], false, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientTypeFiles()[0] var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { @@ -96,8 +94,8 @@ func renderClientDecodeCode(t *testing.T, dsl func()) string { t.Helper() root := expr.RunDSL(t, dsl) - services := CreateHTTPServices(root) - fs := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates diff --git a/http/codegen/openapi.go b/http/codegen/openapi.go index af0f42fb5d..db7055ec47 100644 --- a/http/codegen/openapi.go +++ b/http/codegen/openapi.go @@ -1,6 +1,13 @@ +// This file reads one HTTP design and builds the requested OpenAPI files. +// The plan builds every file immediately and returns those files later without +// reading the design again. package codegen import ( + "fmt" + "path" + "strings" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/openapi" @@ -8,33 +15,140 @@ import ( openapiv3 "goa.design/goa/v3/http/codegen/openapi/v3" ) -// OpenAPIFiles returns the files for the OpenAPI specs of the given HTTP API. +type ( + // OpenAPIPlan stores the OpenAPI files built from one HTTP design. + OpenAPIPlan struct { + files []*codegen.File + } +) + +// NewOpenAPIPlan builds the OpenAPI files for root. Later calls to Files +// return these same files without reading root again. // The "openapi:versions" API meta selects the generated specification // versions and the "openapi:path:" API meta overrides their output // paths, see openapi.Specs. -func OpenAPIFiles(root *expr.RootExpr) ([]*codegen.File, error) { - // Only create a OpenAPI specification if there are HTTP services. - if len(root.API.HTTP.Services) == 0 { - return nil, nil - } +func NewOpenAPIPlan(root *expr.RootExpr, generator *expr.ExampleGenerator) (*OpenAPIPlan, error) { + return NewOpenAPIPlanWithValues(root, generator, openapi.Values{}) +} +// NewOpenAPIPlanWithValues builds OpenAPI files using values in place of +// matching titles, descriptions, and examples from the evaluated design. +func NewOpenAPIPlanWithValues(root *expr.RootExpr, generator *expr.ExampleGenerator, values openapi.Values) (*OpenAPIPlan, error) { specs, err := openapi.Specs(root.API.Meta) if err != nil { return nil, err } + return NewOpenAPIPlanFromSpecs(root, generator, specs, values) +} + +// NewOpenAPIPlanFromSpecs builds the exact OpenAPI versions and paths in specs, +// using values in place of matching design text and examples. Paths are +// relative to the gen directory and omit the JSON or YAML extension because +// Goa writes both formats. +func NewOpenAPIPlanFromSpecs(root *expr.RootExpr, generator *expr.ExampleGenerator, specs []openapi.Spec, values openapi.Values) (*OpenAPIPlan, error) { + if err := validateOpenAPISpecs(specs); err != nil { + return nil, err + } + // Only create a OpenAPI specification if there are HTTP services. + if len(root.API.HTTP.Services) == 0 { + return &OpenAPIPlan{}, nil + } + var files []*codegen.File for _, spec := range specs { - var fs []*codegen.File + specGenerator := generator + if examplesDisabled(root.API.Meta) { + specGenerator = &expr.ExampleGenerator{} + } + var ( + fs []*codegen.File + err error + ) switch spec.Version { case openapi.Version20: - fs, err = openapiv2.Files(root, spec.Path) + fs, err = openapiv2.FilesWithValues(root, spec.Path, specGenerator, values) if err != nil { return nil, err } default: // Version30, Version32 - fs = openapiv3.Files(root, spec.Version, spec.Path) + fs = openapiv3.FilesWithValues(root, spec.Version, spec.Path, specGenerator, values) } files = append(files, fs...) } - return files, nil + return &OpenAPIPlan{files: files}, nil +} + +// Files returns the OpenAPI files built when the plan was created. +func (p *OpenAPIPlan) Files() []*codegen.File { + return p.files +} + +// examplesDisabled reports whether API metadata suppresses examples from +// generated OpenAPI documents. +func examplesDisabled(meta expr.MetaExpr) bool { + value, ok := meta.Last("openapi:example") + if !ok { + value, ok = meta.Last("swagger:example") + } + return ok && value == "false" +} + +// validateOpenAPISpecs checks the complete version and path list before any +// file is built. +func validateOpenAPISpecs(specs []openapi.Spec) error { + versions := make(map[openapi.Version]struct{}, len(specs)) + paths := make(map[string]openapi.Spec, len(specs)) + for _, spec := range specs { + switch spec.Version { + case openapi.Version20, openapi.Version30, openapi.Version32: + default: + return fmt.Errorf("unsupported OpenAPI version %q", spec.Version) + } + if _, ok := versions[spec.Version]; ok { + return fmt.Errorf("OpenAPI version %q appears more than once", spec.Version) + } + versions[spec.Version] = struct{}{} + if err := validateOpenAPIPath(spec.Path); err != nil { + return fmt.Errorf("invalid OpenAPI %s path %q: %w", spec.Version, spec.Path, err) + } + for existingPath, existing := range paths { + if existingPath == spec.Path { + return fmt.Errorf("OpenAPI versions %s and %s use the same output path %q", existing.Version, spec.Version, spec.Path) + } + if strings.EqualFold(existingPath, spec.Path) { + return fmt.Errorf( + "OpenAPI paths %q and %q collide on a case-insensitive filesystem", + existingPath, + spec.Path, + ) + } + } + paths[spec.Path] = spec + } + return nil +} + +// validateOpenAPIPath checks one extension-less path relative to gen. +func validateOpenAPIPath(outputPath string) error { + if outputPath == "" { + return fmt.Errorf("path cannot be empty") + } + if strings.Contains(outputPath, "\\") { + return fmt.Errorf("path cannot contain a backslash") + } + if strings.HasPrefix(outputPath, "/") { + return fmt.Errorf("path must be relative to the gen directory") + } + cleaned := path.Clean(outputPath) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return fmt.Errorf("path must not escape the gen directory") + } + if cleaned != outputPath { + return fmt.Errorf("path must be clean; use %q", cleaned) + } + switch path.Ext(outputPath) { + case ".json", ".yaml", ".yml": + return fmt.Errorf("path must not include an extension") + } + return nil } diff --git a/http/codegen/openapi/docs.go b/http/codegen/openapi/docs.go index a6d63045c2..d5d44e679f 100644 --- a/http/codegen/openapi/docs.go +++ b/http/codegen/openapi/docs.go @@ -1,3 +1,5 @@ +// This file converts Goa documentation links into OpenAPI values while +// allowing one specification build to replace their displayed description. package openapi import "goa.design/goa/v3/expr" @@ -12,11 +14,23 @@ type ExternalDocs struct { // DocsFromExpr builds a ExternalDocs from the Goa docs expression. func DocsFromExpr(docs *expr.DocsExpr, meta expr.MetaExpr) *ExternalDocs { + return docsFromExpr(docs, meta, Values{}) +} + +// DocsFromExprWithValues builds ExternalDocs and uses values for its +// description when one is present for docs. +func DocsFromExprWithValues(docs *expr.DocsExpr, meta expr.MetaExpr, values Values) *ExternalDocs { + return docsFromExpr(docs, meta, values) +} + +// docsFromExpr is the one implementation used by ordinary and customized +// OpenAPI builds. +func docsFromExpr(docs *expr.DocsExpr, meta expr.MetaExpr, values Values) *ExternalDocs { if docs == nil { return nil } return &ExternalDocs{ - Description: docs.Description, + Description: values.Description(docs, docs.Description), URL: docs.URL, Extensions: ExtensionsFromExpr(meta), } diff --git a/http/codegen/openapi/error_example.go b/http/codegen/openapi/error_example.go new file mode 100644 index 0000000000..2ca50d95ce --- /dev/null +++ b/http/codegen/openapi/error_example.go @@ -0,0 +1,51 @@ +// This file derives HTTP response details shared by the OpenAPI 2 and OpenAPI +// 3 generators. The shared Error schema stays reusable while each response +// shows the exact flags generated by its service constructor. +package openapi + +import "goa.design/goa/v3/expr" + +// ResponseContentType returns the content type used for one HTTP response. A +// response setting wins, followed by its result type and application/json. +func ResponseContentType(response *expr.HTTPResponseExpr) string { + if response.ContentType != "" { + return response.ContentType + } + if result, ok := response.Body.Type.(*expr.ResultTypeExpr); ok && result.ContentType != "" { + return result.ContentType + } + return "application/json" +} + +// ErrorResponseExample returns the generated body example for one use of +// Goa's built-in error result. It returns false when the error uses another +// type, supplies an authored example, or suppresses generated examples. +func ErrorResponseExample(errorExpression *expr.ErrorExpr, body *expr.AttributeExpr, generator *expr.ExampleGenerator, values Values) (any, bool) { + if !expr.IsErrorResult(errorExpression.Type) || len(values.Examples(body, body.ExtractUserExamples())) > 0 { + return nil, false + } + example := ProjectExample(body, values.Example(body, generator)) + if example == nil { + return nil, false + } + object, ok := example.(map[string]any) + if !ok { + return example, true + } + setExampleField(object, "name", errorExpression.Name) + _, temporary := errorExpression.Meta["goa:error:temporary"] + _, timeout := errorExpression.Meta["goa:error:timeout"] + _, fault := errorExpression.Meta["goa:error:fault"] + setExampleField(object, "temporary", temporary) + setExampleField(object, "timeout", timeout) + setExampleField(object, "fault", fault) + return object, true +} + +// setExampleField changes a field only when that field is part of the HTTP +// response body. Fields mapped to headers or cookies are not added back. +func setExampleField(example map[string]any, name string, value any) { + if _, ok := example[name]; ok { + example[name] = value + } +} diff --git a/http/codegen/openapi/json_schema.go b/http/codegen/openapi/json_schema.go index 287dbda967..9f3e627fd0 100644 --- a/http/codegen/openapi/json_schema.go +++ b/http/codegen/openapi/json_schema.go @@ -1,12 +1,13 @@ +// This file defines the JSON schema values shared by the OpenAPI generators. +// It also converts Goa examples into the fields visible in those schemas. package openapi import ( + "encoding/base64" "encoding/json" - "fmt" "reflect" "strconv" - "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" ) @@ -108,26 +109,6 @@ const ( // SchemaRef is the JSON Schema draft 2020-12 meta-schema identifier. const SchemaRef = "https://json-schema.org/draft/2020-12/schema" -var ( - // Definitions contains the generated JSON schema definitions - Definitions map[string]*Schema - - // definitionNames records the definition name assigned to result type - // expressions returned as-is by expr.Project (the design expression when - // it is already projected onto the requested view). The historical - // implementation renamed those expressions in place which made later - // references resolve to the first assigned name; the registry preserves - // that behavior while keeping the design expression tree read-only for - // the generators. Entries are keyed by expression instance so stale - // entries from previous generations cannot collide with new designs. - definitionNames = make(map[*expr.ResultTypeExpr]string) -) - -// Initialize the global variables -func init() { - Definitions = make(map[string]*Schema) -} - // NewSchema instantiates a new JSON schema. func NewSchema() *Schema { js := Schema{ @@ -146,313 +127,6 @@ func (s *Schema) JSON() ([]byte, error) { return json.Marshal(s) } -// APISchema produces the API JSON hyper schema. -func APISchema(api *expr.APIExpr, r *expr.RootExpr) *Schema { - for _, res := range r.API.HTTP.Services { - GenerateServiceDefinition(api, res) - } - href := string(api.Servers[0].Hosts[0].URIs[0]) - links := []*Link{ - { - Href: href, - Rel: "self", - }, - { - Href: "/schema", - Method: "GET", - Rel: "self", - TargetSchema: &Schema{ - Schema: SchemaRef, - AdditionalProperties: true, - }, - }, - } - s := Schema{ - ID: fmt.Sprintf("%s/schema", href), - Title: api.Title, - Description: api.Description, - Type: Object, - Defs: Definitions, - Properties: propertiesFromDefs(Definitions, "#/$defs/"), - Links: links, - } - return &s -} - -// GenerateServiceDefinition produces the JSON schema corresponding to the given -// service. It stores the results in Definitions. -func GenerateServiceDefinition(api *expr.APIExpr, res *expr.HTTPServiceExpr) { - s := NewSchema() - s.Description = res.Description() - s.Type = Object - s.Title = res.Name() - Definitions[res.Name()] = s - for _, a := range res.HTTPEndpoints { - var requestSchema *Schema - if a.MethodExpr.Payload.Type != expr.Empty { - requestSchema = AttributeTypeSchema(api, a.MethodExpr.Payload) - requestSchema.Description = a.Name() + " payload" - } - var targetSchema *Schema - var identifier string - for _, resp := range a.Responses { - dt := resp.Body.Type - if mt := dt.(*expr.ResultTypeExpr); mt != nil { - if identifier == "" { - identifier = mt.Identifier - } else { - identifier = "" - } - switch { - case targetSchema == nil: - targetSchema = TypeSchemaWithPrefix(api, mt, a.Name()) - case targetSchema.AnyOf == nil: - firstSchema := targetSchema - targetSchema = NewSchema() - targetSchema.AnyOf = []*Schema{firstSchema, TypeSchemaWithPrefix(api, mt, a.Name())} - default: - targetSchema.AnyOf = append(targetSchema.AnyOf, TypeSchemaWithPrefix(api, mt, a.Name())) - } - } - } - for i, r := range a.Routes { - for j, href := range toSchemaHrefs(r) { - link := Link{ - Title: a.Name(), - Rel: a.Name(), - Href: href, - Method: r.Method, - Schema: requestSchema, - TargetSchema: targetSchema, - ResultType: identifier, - } - if i == 0 && j == 0 { - if ca := a.Service.CanonicalEndpoint(); ca != nil { - if ca.Name() == a.Name() { - link.Rel = "self" - } - } - } - s.Links = append(s.Links, &link) - } - } - } -} - -// ResultTypeRef produces the JSON reference to the media type definition with -// the given view. -func ResultTypeRef(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string) string { - return ResultTypeRefWithPrefix(api, mt, view, "") -} - -// ResultTypeRefWithPrefix produces the JSON reference to the media type definition with -// the given view and adds the provided prefix to the type name -func ResultTypeRefWithPrefix(api *expr.APIExpr, mt *expr.ResultTypeExpr, view, prefix string) string { - projected, err := expr.Project(mt, view) - if err != nil { - panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug - } - var metaName string - if n, ok := mt.Meta["openapi:typename"]; ok { - metaName = codegen.Goify(n[0], true) - } - name := projected.TypeName - if metaName != "" { - name = metaName - } - if assigned, ok := definitionNames[projected]; ok { - // expr.Project returned the design expression itself and a - // definition name was already assigned to it: keep referencing it. - name = assigned - } else { - if _, ok := Definitions[name]; !ok { - name = codegen.Goify(prefix, true) + codegen.Goify(name, true) - if metaName != "" { - name = metaName - } - } - if projected == mt { - // expr.Project returns its input when the result type is - // already projected onto the requested view. Record the - // assigned name instead of renaming the design expression in - // place: the design tree is read-only for the generators. - definitionNames[projected] = name - } - } - if _, ok := Definitions[name]; !ok { - GenerateResultTypeDefinition(api, renamedResultType(projected, name), expr.DefaultView) - } - return fmt.Sprintf("#/$defs/%s", name) -} - -// TypeRef produces the JSON reference to the type definition. -func TypeRef(api *expr.APIExpr, ut *expr.UserTypeExpr) string { - return TypeRefWithPrefix(api, ut, "") -} - -// TypeRefWithPrefix produces the JSON reference to the type definition and adds the provided prefix -// to the type name -func TypeRefWithPrefix(api *expr.APIExpr, ut *expr.UserTypeExpr, prefix string) string { - typeName := ut.TypeName - if prefix != "" { - typeName = codegen.Goify(prefix, true) + codegen.Goify(ut.TypeName, true) - } - if n, ok := ut.Meta["openapi:typename"]; ok { - typeName = codegen.Goify(n[0], true) - } - if _, ok := Definitions[typeName]; !ok { - GenerateTypeDefinitionWithName(api, ut, typeName) - } - return fmt.Sprintf("#/$defs/%s", typeName) -} - -// GenerateResultTypeDefinition produces the JSON schema corresponding to the -// given media type and given view. -func GenerateResultTypeDefinition(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string) { - if _, ok := Definitions[mt.TypeName]; ok { - return - } - s := NewSchema() - s.Title = fmt.Sprintf("Mediatype identifier: %s", mt.Identifier) - Definitions[mt.TypeName] = s - buildResultTypeSchema(api, mt, view, s) -} - -// GenerateTypeDefinition produces the JSON schema corresponding to the given -// type. -func GenerateTypeDefinition(api *expr.APIExpr, ut *expr.UserTypeExpr) { - GenerateTypeDefinitionWithName(api, ut, ut.TypeName) -} - -// GenerateTypeDefinitionWithName produces the JSON schema corresponding to the given -// type with provided type name. -func GenerateTypeDefinitionWithName(api *expr.APIExpr, ut *expr.UserTypeExpr, typeName string) { - if _, ok := Definitions[typeName]; ok { - return - } - s := NewSchema() - - s.Title = typeName - Definitions[typeName] = s - buildAttributeSchema(api, s, ut.AttributeExpr, api.ExampleGenerator.Rebased(ut.ID())) -} - -// TypeSchema produces the JSON schema corresponding to the given data type. -func TypeSchema(api *expr.APIExpr, t expr.DataType) *Schema { - return TypeSchemaWithPrefix(api, t, "") -} - -// TypeSchemaWithPrefix produces the JSON schema corresponding to the given data type -// and adds the provided prefix to the type name -func TypeSchemaWithPrefix(api *expr.APIExpr, t expr.DataType, prefix string) *Schema { - return typeSchemaWithGen(api, t, prefix, api.ExampleGenerator) -} - -// typeSchemaWithGen builds the JSON schema for t drawing example values from -// gen. Child schemas derive their example streams from their position (object -// property name, array index, map entry, union member) so every example in -// the schema is anchored to the design element it illustrates. -func typeSchemaWithGen(api *expr.APIExpr, t expr.DataType, prefix string, gen *expr.ExampleGenerator) *Schema { - s := NewSchema() - switch actual := t.(type) { - case expr.Primitive: - s.Type = Type(actual.Name()) - switch actual.Kind() { - case expr.AnyKind: - // A schema without a type matches any data type. - // See https://swagger.io/docs/specification/data-models/data-types/#any. - s.Type = Type("") - case expr.IntKind, expr.Int64Kind, - expr.UIntKind, expr.UInt64Kind: - // Use int64 format for IntKind and UIntKind because the OpenAPI - // generator produced int32 by default. - s.Type = Type("integer") - s.Format = "int64" - case expr.Int32Kind, expr.UInt32Kind: - s.Type = Type("integer") - s.Format = "int32" - case expr.Float32Kind: - s.Type = Type("number") - s.Format = "float" - case expr.Float64Kind: - s.Type = Type("number") - s.Format = "double" - case expr.BytesKind: - s.Type = Type("string") - s.Format = "byte" - } - case *expr.Array: - s.Type = Array - s.Items = NewSchema() - buildAttributeSchema(api, s.Items, actual.ElemType, gen.Derived("0")) - case *expr.Object: - s.Type = Object - for _, nat := range *actual { - if !MustGenerate(nat.Attribute.Meta) { - continue - } - prop := NewSchema() - buildAttributeSchema(api, prop, nat.Attribute, gen.Derived(nat.Name)) - s.Properties[nat.Name] = prop - } - case *expr.Map: - s.Type = Object - if actual.KeyType.Type == expr.String && actual.ElemType.Type != expr.Any { - // Use free-form objects when elements are of type "Any" - additionalProperties := NewSchema() - s.AdditionalProperties = buildAttributeSchema(api, additionalProperties, actual.ElemType, gen.Derived("val0")) - } else { - s.AdditionalProperties = true - } - case *expr.Union: - // Each branch owns both its discriminator literal and value schema so - // clients cannot combine one branch tag with another branch value. - typeKey := actual.GetTypeKey() - valueKey := actual.GetValueKey() - - s.Type = Object - for _, val := range actual.Values { - valueSchema := typeSchemaWithGen(api, val.Attribute.Type, prefix, gen.Derived(val.Name)) - initAttributeValidation(valueSchema, val.Attribute) - s.AnyOf = append(s.AnyOf, &Schema{ - Type: Object, - Properties: map[string]*Schema{ - typeKey: { - Type: String, - Enum: []any{val.Name}, - }, - valueKey: valueSchema, - }, - Required: []string{typeKey, valueKey}, - }) - } - case *expr.UserTypeExpr: - if expr.IsAlias(actual) { - s = typeSchemaWithGen(api, actual.Attribute().Type, prefix, gen.Rebased(actual.ID())) - initAttributeValidation(s, actual.Attribute()) - break - } - s.Ref = TypeRefWithPrefix(api, actual, prefix) - case *expr.ResultTypeExpr: - // Use "default" view by default - s.Ref = ResultTypeRefWithPrefix(api, actual, expr.DefaultView, prefix) - } - return s -} - -// AttributeTypeSchema produces the JSON schema corresponding to the given attribute. -func AttributeTypeSchema(api *expr.APIExpr, at *expr.AttributeExpr) *Schema { - return AttributeTypeSchemaWithPrefix(api, at, "") -} - -// AttributeTypeSchemaWithPrefix produces the JSON schema corresponding to the given attribute -// and adds the provided prefix to the type name -func AttributeTypeSchemaWithPrefix(api *expr.APIExpr, at *expr.AttributeExpr, prefix string) *Schema { - s := TypeSchemaWithPrefix(api, at.Type, prefix) - initAttributeValidation(s, at) - return s -} - // ToString returns the string representation of the given type. func ToString(val any) string { switch actual := val.(type) { @@ -514,38 +188,75 @@ func (s *Schema) MarshalYAML() (any, error) { return MarshalYAML((*_Schema)(s), s.Extensions) } -// Dup creates a shallow clone of the given schema. +// Dup returns an independent copy of the schema. Callers may change any nested +// schema, collection, example, default, or extension without changing s. func (s *Schema) Dup() *Schema { js := Schema{ - ID: s.ID, - Description: s.Description, - Schema: s.Schema, - Type: s.Type, - DefaultValue: s.DefaultValue, - Title: s.Title, - Media: s.Media, - ReadOnly: s.ReadOnly, - PathStart: s.PathStart, - Links: s.Links, - Ref: s.Ref, - Enum: s.Enum, - Format: s.Format, - Pattern: s.Pattern, - Minimum: s.Minimum, - Maximum: s.Maximum, - MinLength: s.MinLength, - MaxLength: s.MaxLength, - MinItems: s.MinItems, - MaxItems: s.MaxItems, - Required: s.Required, - AdditionalProperties: s.AdditionalProperties, - ContentMediaType: s.ContentMediaType, + Schema: s.Schema, + ID: s.ID, + Title: s.Title, + Type: s.Type, + Description: s.Description, + DefaultValue: duplicateJSONValue(s.DefaultValue), + Example: duplicateJSONValue(s.Example), + ReadOnly: s.ReadOnly, + PathStart: s.PathStart, + Ref: s.Ref, + Format: s.Format, + Pattern: s.Pattern, + ExclusiveMinimum: duplicatePointer(s.ExclusiveMinimum), + Minimum: duplicatePointer(s.Minimum), + ExclusiveMaximum: duplicatePointer(s.ExclusiveMaximum), + Maximum: duplicatePointer(s.Maximum), + MinLength: duplicatePointer(s.MinLength), + MaxLength: duplicatePointer(s.MaxLength), + MinItems: duplicatePointer(s.MinItems), + MaxItems: duplicatePointer(s.MaxItems), + Required: append([]string(nil), s.Required...), + ContentMediaType: s.ContentMediaType, + } + if s.Media != nil { + media := *s.Media + js.Media = &media + } + if s.Links != nil { + js.Links = make([]*Link, len(s.Links)) + for index, link := range s.Links { + copy := *link + if link.Schema != nil { + copy.Schema = link.Schema.Dup() + } + if link.TargetSchema != nil { + copy.TargetSchema = link.TargetSchema.Dup() + } + js.Links[index] = © + } + } + if s.Enum != nil { + js.Enum = make([]any, len(s.Enum)) + for index, value := range s.Enum { + js.Enum[index] = duplicateJSONValue(value) + } + } + if additional, ok := s.AdditionalProperties.(*Schema); ok { + js.AdditionalProperties = additional.Dup() + } else { + js.AdditionalProperties = duplicateJSONValue(s.AdditionalProperties) + } + if s.Extensions != nil { + js.Extensions = make(map[string]any, len(s.Extensions)) + for name, value := range s.Extensions { + js.Extensions[name] = duplicateJSONValue(value) + } } if s.ContentSchema != nil { js.ContentSchema = s.ContentSchema.Dup() } - for n, p := range s.Properties { - js.Properties[n] = p.Dup() + if s.Properties != nil { + js.Properties = make(map[string]*Schema, len(s.Properties)) + for name, property := range s.Properties { + js.Properties[name] = property.Dup() + } } if s.Items != nil { js.Items = s.Items.Dup() @@ -556,138 +267,13 @@ func (s *Schema) Dup() *Schema { js.AnyOf[i] = branch.Dup() } } - for n, d := range s.Defs { - js.Defs[n] = d.Dup() - } - return &js -} - -// buildAttributeSchema initializes the given JSON schema that corresponds to -// the given attribute, drawing example values from gen. -func buildAttributeSchema(api *expr.APIExpr, s *Schema, at *expr.AttributeExpr, gen *expr.ExampleGenerator) *Schema { - s.Merge(typeSchemaWithGen(api, at.Type, "", gen)) - if s.Ref != "" { - // Ref is exclusive with other fields - return s - } - s.DefaultValue = ToStringMap(at.DefaultValue) - if at.Description != "" { - s.Description = at.Description - } - s.Example = ProjectExample(at, at.Example(gen)) - s.Extensions = ExtensionsFromExpr(at.Meta) - if ap := AdditionalPropertiesFromExpr(at.Meta); ap != nil { - s.AdditionalProperties = ap - } - initAttributeValidation(s, at) - - return s -} - -// initAttributeValidation initializes validation rules for an attribute. -func initAttributeValidation(s *Schema, at *expr.AttributeExpr) { - val := at.Validation - if val == nil { - return - } - s.Enum = val.Values - if val.Format != "" { - s.Format = string(val.Format) - } - s.Pattern = val.Pattern - if val.ExclusiveMinimum != nil { - s.ExclusiveMinimum = val.ExclusiveMinimum - } - if val.Minimum != nil { - s.Minimum = val.Minimum - } - if val.ExclusiveMaximum != nil { - s.ExclusiveMaximum = val.ExclusiveMaximum - } - if val.Maximum != nil { - s.Maximum = val.Maximum - } - if val.MinLength != nil { - if _, ok := at.Type.(*expr.Array); ok { - s.MinItems = val.MinLength - } else { - s.MinLength = val.MinLength - } - } - if val.MaxLength != nil { - if _, ok := at.Type.(*expr.Array); ok { - s.MaxItems = val.MaxLength - } else { - s.MaxLength = val.MaxLength - } - } - for _, v := range val.Required { - if a := at.Find(v); a != nil { - if !MustGenerate(a.Meta) { - continue - } - } - s.Required = append(s.Required, v) - } -} - -// renamedResultType returns rt carrying the given type name. When the name -// already matches it returns rt unchanged, otherwise it returns a shallow -// copy sharing the attribute, views and identifier so the schema definition -// is registered under the assigned name without renaming the (possibly design -// owned) expression in place. -func renamedResultType(rt *expr.ResultTypeExpr, name string) *expr.ResultTypeExpr { - if rt.TypeName == name { - return rt - } - ut := *rt.UserTypeExpr - ut.TypeName = name - dup := *rt - dup.UserTypeExpr = &ut - return &dup -} - -// toSchemaHrefs produces hrefs that replace the path wildcards with JSON -// schema references when appropriate. -func toSchemaHrefs(r *expr.RouteExpr) []string { - paths := r.FullPaths() - res := make([]string, len(paths)) - for i, path := range paths { - params := expr.ExtractHTTPWildcards(path) - args := make([]any, len(params)) - for j, p := range params { - args[j] = fmt.Sprintf("/{%s}", p) - } - tmpl := expr.HTTPWildcardRegex.ReplaceAllLiteralString(path, "%s") - res[i] = fmt.Sprintf(tmpl, args...) - } - return res -} - -// propertiesFromDefs creates a Properties map referencing the given definitions -// under the given path. -func propertiesFromDefs(definitions map[string]*Schema, path string) map[string]*Schema { - res := make(map[string]*Schema, len(definitions)) - for n := range definitions { - if n == "identity" { - continue + if s.Defs != nil { + js.Defs = make(map[string]*Schema, len(s.Defs)) + for name, definition := range s.Defs { + js.Defs[name] = definition.Dup() } - s := NewSchema() - s.Ref = path + n - res[n] = s } - return res -} - -// buildResultTypeSchema initializes s as the JSON schema representing mt for the -// given view. -func buildResultTypeSchema(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, s *Schema) { - s.Media = &Media{Type: mt.Identifier} - projected, err := expr.Project(mt, view) - if err != nil { - panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug - } - buildAttributeSchema(api, s, projected.AttributeExpr, api.ExampleGenerator.Rebased(projected.ID())) + return &js } // MustGenerate returns true if the meta indicates that a OpenAPI specification should be @@ -714,6 +300,11 @@ func AdditionalPropertiesFromExpr(meta expr.MetaExpr) any { func projectExample(t expr.DataType, val any) any { switch actual := t.(type) { + case expr.Primitive: + if actual.Kind() == expr.BytesKind { + return base64.StdEncoding.EncodeToString(reflect.ValueOf(val).Bytes()) + } + return ToStringMap(val) case *expr.UserTypeExpr: return ProjectExample(actual.Attribute(), val) case *expr.ResultTypeExpr: @@ -729,6 +320,75 @@ func projectExample(t expr.DataType, val any) any { } } +// duplicateJSONValue copies the maps, slices, arrays, pointers, and interface +// values accepted by JSON fields while preserving their concrete Go types. +func duplicateJSONValue(value any) any { + if value == nil { + return nil + } + return duplicateJSONReflectValue(reflect.ValueOf(value)).Interface() +} + +// duplicateJSONReflectValue recursively copies one reflected JSON value. +func duplicateJSONReflectValue(value reflect.Value) reflect.Value { + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copy := duplicateJSONReflectValue(value.Elem()) + result := reflect.New(value.Type()).Elem() + result.Set(copy) + return result + case reflect.Pointer: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copy := reflect.New(value.Type().Elem()) + copy.Elem().Set(duplicateJSONReflectValue(value.Elem())) + return copy + case reflect.Map: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copy := reflect.MakeMapWithSize(value.Type(), value.Len()) + iterator := value.MapRange() + for iterator.Next() { + copy.SetMapIndex( + duplicateJSONReflectValue(iterator.Key()), + duplicateJSONReflectValue(iterator.Value()), + ) + } + return copy + case reflect.Slice: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copy := reflect.MakeSlice(value.Type(), value.Len(), value.Cap()) + for index := range value.Len() { + copy.Index(index).Set(duplicateJSONReflectValue(value.Index(index))) + } + return copy + case reflect.Array: + copy := reflect.New(value.Type()).Elem() + for index := range value.Len() { + copy.Index(index).Set(duplicateJSONReflectValue(value.Index(index))) + } + return copy + default: + return value + } +} + +// duplicatePointer copies one scalar schema limit while preserving nil. +func duplicatePointer[T any](value *T) *T { + if value == nil { + return nil + } + copy := *value + return © +} + func projectObjectExample(obj *expr.Object, val any) any { values, ok := exampleMap(val) if !ok { diff --git a/http/codegen/openapi/json_schema_dup_test.go b/http/codegen/openapi/json_schema_dup_test.go new file mode 100644 index 0000000000..f7841bbb06 --- /dev/null +++ b/http/codegen/openapi/json_schema_dup_test.go @@ -0,0 +1,105 @@ +// This file verifies that copied schemas share no mutable values with their +// source. Plugins may safely edit a copy without changing Goa's planned schema. +package openapi + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSchemaDupCopiesEveryMutableField(t *testing.T) { + exclusiveMinimum := 1.0 + minimum := 2.0 + exclusiveMaximum := 9.0 + maximum := 10.0 + minLength := 1 + maxLength := 8 + minItems := 2 + maxItems := 4 + original := &Schema{ + Schema: "schema", + ID: "id", + Title: "title", + Type: Object, + Items: &Schema{Title: "items"}, + Properties: map[string]*Schema{"property": {Title: "property"}}, + Defs: map[string]*Schema{"definition": {Title: "definition"}}, + Description: "description", + DefaultValue: map[string][]string{"values": {"default"}}, + Example: []map[string]any{{"value": "example"}}, + Media: &Media{BinaryEncoding: "binary", Type: "media"}, + ReadOnly: true, + PathStart: "/path", + Links: []*Link{{Title: "link", Schema: &Schema{Title: "link schema"}, TargetSchema: &Schema{Title: "target schema"}}}, + Ref: "#/$defs/reference", + Enum: []any{[]string{"enum"}}, + Format: "format", + Pattern: "pattern", + ExclusiveMinimum: &exclusiveMinimum, + Minimum: &minimum, + ExclusiveMaximum: &exclusiveMaximum, + Maximum: &maximum, + MinLength: &minLength, + MaxLength: &maxLength, + MinItems: &minItems, + MaxItems: &maxItems, + Required: []string{"required"}, + AdditionalProperties: &Schema{Title: "additional properties"}, + ContentMediaType: "application/json", + ContentSchema: &Schema{Title: "content schema"}, + AnyOf: []*Schema{{Title: "union branch"}}, + Extensions: map[string]any{"x-values": []string{"extension"}}, + } + + duplicate := original.Dup() + require.Equal(t, original, duplicate) + + duplicate.Items.Title = "changed" + duplicate.Properties["property"].Title = "changed" + duplicate.Defs["definition"].Title = "changed" + duplicate.DefaultValue.(map[string][]string)["values"][0] = "changed" + duplicate.Example.([]map[string]any)[0]["value"] = "changed" + duplicate.Media.Type = "changed" + duplicate.Links[0].Title = "changed" + duplicate.Links[0].Schema.Title = "changed" + duplicate.Links[0].TargetSchema.Title = "changed" + duplicate.Enum[0].([]string)[0] = "changed" + *duplicate.ExclusiveMinimum = 3 + *duplicate.Minimum = 4 + *duplicate.ExclusiveMaximum = 7 + *duplicate.Maximum = 8 + *duplicate.MinLength = 2 + *duplicate.MaxLength = 7 + *duplicate.MinItems = 1 + *duplicate.MaxItems = 3 + duplicate.Required[0] = "changed" + duplicate.AdditionalProperties.(*Schema).Title = "changed" + duplicate.ContentSchema.Title = "changed" + duplicate.AnyOf[0].Title = "changed" + duplicate.Extensions["x-values"].([]string)[0] = "changed" + + require.Equal(t, "items", original.Items.Title) + require.Equal(t, "property", original.Properties["property"].Title) + require.Equal(t, "definition", original.Defs["definition"].Title) + require.Equal(t, "default", original.DefaultValue.(map[string][]string)["values"][0]) + require.Equal(t, "example", original.Example.([]map[string]any)[0]["value"]) + require.Equal(t, "media", original.Media.Type) + require.Equal(t, "link", original.Links[0].Title) + require.Equal(t, "link schema", original.Links[0].Schema.Title) + require.Equal(t, "target schema", original.Links[0].TargetSchema.Title) + require.Equal(t, "enum", original.Enum[0].([]string)[0]) + require.Equal(t, 1.0, *original.ExclusiveMinimum) + require.Equal(t, 2.0, *original.Minimum) + require.Equal(t, 9.0, *original.ExclusiveMaximum) + require.Equal(t, 10.0, *original.Maximum) + require.Equal(t, 1, *original.MinLength) + require.Equal(t, 8, *original.MaxLength) + require.Equal(t, 2, *original.MinItems) + require.Equal(t, 4, *original.MaxItems) + require.Equal(t, "required", original.Required[0]) + require.Equal(t, "additional properties", original.AdditionalProperties.(*Schema).Title) + require.Equal(t, "content schema", original.ContentSchema.Title) + require.Equal(t, "union branch", original.AnyOf[0].Title) + require.Equal(t, "extension", original.Extensions["x-values"].([]string)[0]) +} diff --git a/http/codegen/openapi/json_schema_union_test.go b/http/codegen/openapi/json_schema_union_test.go deleted file mode 100644 index cecfd4b46a..0000000000 --- a/http/codegen/openapi/json_schema_union_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package openapi - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "goa.design/goa/v3/expr" -) - -func TestAttributeTypeSchemaCorrelatesUnionDiscriminatorAndValue(t *testing.T) { - schema := AttributeTypeSchema(&expr.APIExpr{ExampleGenerator: expr.NewRandom("test")}, unionAttribute()) - - require.Len(t, schema.AnyOf, 2) - assertUnionSchemaBranch(t, schema.AnyOf[0], "text", Type(String)) - assertUnionSchemaBranch(t, schema.AnyOf[1], "count", Type(Integer)) - assert.Empty(t, schema.Properties) -} - -func assertUnionSchemaBranch(t *testing.T, branch *Schema, tag string, valueType Type) { - t.Helper() - assert.Equal(t, Type(Object), branch.Type) - assert.Equal(t, []string{"type", "value"}, branch.Required) - require.Contains(t, branch.Properties, "type") - assert.Equal(t, []any{tag}, branch.Properties["type"].Enum) - require.Contains(t, branch.Properties, "value") - assert.Equal(t, valueType, branch.Properties["value"].Type) -} - -func unionAttribute() *expr.AttributeExpr { - return &expr.AttributeExpr{ - Type: &expr.Union{ - TypeName: "outcome", - Values: []*expr.NamedAttributeExpr{ - {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, - {Name: "count", Attribute: &expr.AttributeExpr{Type: expr.Int}}, - }, - }, - } -} diff --git a/http/codegen/openapi/response_projection.go b/http/codegen/openapi/response_projection.go new file mode 100644 index 0000000000..c31da2031c --- /dev/null +++ b/http/codegen/openapi/response_projection.go @@ -0,0 +1,47 @@ +// This file selects a response view on a private result type copy and keeps +// the component names produced by released Goa versions. +package openapi + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// ResponseProjection contains one detached response result and the types whose +// released names should be preferred when they each describe one schema. +type ResponseProjection struct { + Result *expr.ResultTypeExpr + Preferred []expr.UserType +} + +// ProjectResponseResult selects view without changing the design. Collection +// result names keep the Response suffix before the view name. +func ProjectResponseResult(result *expr.ResultTypeExpr, view string) ResponseProjection { + projected, err := expr.Project(result, view) + if err != nil { + panic(fmt.Sprintf("failed to project result type %q to view %q: %s", result.Identifier, view, err)) + } + copy := expr.DupAtt(&expr.AttributeExpr{Type: projected}).Type.(*expr.ResultTypeExpr) + originalArray := expr.AsArray(result.Type) + projectedArray := expr.AsArray(copy.Type) + if originalArray == nil || projectedArray == nil { + return ResponseProjection{Result: copy} + } + originalElement, originalNamed := originalArray.ElemType.Type.(*expr.ResultTypeExpr) + projectedElement, projectedNamed := projectedArray.ElemType.Type.(*expr.ResultTypeExpr) + if !originalNamed || !projectedNamed { + return ResponseProjection{Result: copy} + } + name := codegen.Goify(originalElement.Name(), true) + "Response" + if view != "" && view != expr.DefaultView { + name += codegen.Goify(view, true) + } + projectedElement.Rename(name) + copy.Rename(name + "Collection") + return ResponseProjection{ + Result: copy, + Preferred: []expr.UserType{copy, projectedElement}, + } +} diff --git a/http/codegen/openapi/v2/build_isolation_test.go b/http/codegen/openapi/v2/build_isolation_test.go new file mode 100644 index 0000000000..4387d59ebb --- /dev/null +++ b/http/codegen/openapi/v2/build_isolation_test.go @@ -0,0 +1,125 @@ +// This file checks that Swagger builds do not share or change each other's schemas. +package openapiv2_test + +import ( + "encoding/json" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + openapiv2 "goa.design/goa/v3/http/codegen/openapi/v2" +) + +func TestBuildsKeepDefinitionsSeparate(t *testing.T) { + firstRoot := expr.RunDSL(t, schemaBuildDSL("first")) + secondRoot := expr.RunDSL(t, schemaBuildDSL("second")) + + first, err := openapiv2.NewV2( + firstRoot, + firstRoot.API.Servers[0].Hosts[0], + ) + require.NoError(t, err) + firstJSON, err := json.Marshal(first) + require.NoError(t, err) + + second, err := openapiv2.NewV2( + secondRoot, + secondRoot.API.Servers[0].Hosts[0], + ) + require.NoError(t, err) + + firstDefinition := definitionWithProperty(first.Definitions, "first") + require.NotNil(t, firstDefinition, "definitions: %#v", first.Definitions) + require.Contains(t, firstDefinition.Properties, "first") + require.NotContains(t, firstDefinition.Properties, "second") + secondDefinition := definitionWithProperty(second.Definitions, "second") + require.NotNil(t, secondDefinition, "definitions: %#v", second.Definitions) + require.Contains(t, secondDefinition.Properties, "second") + require.NotContains(t, secondDefinition.Properties, "first") + + firstJSONAfterSecondBuild, err := json.Marshal(first) + require.NoError(t, err) + require.Equal(t, firstJSON, firstJSONAfterSecondBuild) +} + +func TestBuildsAreSafeToRunTogether(t *testing.T) { + firstRoot := expr.RunDSL(t, schemaBuildDSL("first")) + secondRoot := expr.RunDSL(t, schemaBuildDSL("second")) + + type result struct { + spec *openapiv2.V2 + err error + } + start := make(chan struct{}) + results := make(chan result, 2) + var ready sync.WaitGroup + ready.Add(2) + build := func(root *expr.RootExpr) { + ready.Done() + <-start + spec, err := openapiv2.NewV2( + root, + root.API.Servers[0].Hosts[0], + ) + results <- result{spec: spec, err: err} + } + go build(firstRoot) + go build(secondRoot) + ready.Wait() + close(start) + + properties := make(map[string]int) + for range 2 { + built := <-results + require.NoError(t, built.err) + var found []string + for _, definition := range built.spec.Definitions { + for property := range definition.Properties { + if property == "first" || property == "second" { + found = append(found, property) + } + } + } + require.Len(t, found, 1) + properties[found[0]]++ + } + require.Equal(t, map[string]int{"first": 1, "second": 1}, properties) +} + +// definitionWithProperty finds the returned schema that contains property. +func definitionWithProperty(definitions map[string]*openapi.Schema, property string) *openapi.Schema { + for _, definition := range definitions { + if _, ok := definition.Properties[property]; ok { + return definition + } + } + return nil +} + +// schemaBuildDSL returns an API whose Shared result contains only field. +func schemaBuildDSL(field string) func() { + return func() { + shared := dsl.Type("Shared", func() { + dsl.Attribute(field, dsl.String) + }) + dsl.API("test", func() { + dsl.Server("test", func() { + dsl.Host("localhost", func() { + dsl.URI("https://goa.design") + }) + }) + }) + dsl.Service("testService", func() { + dsl.Method("show", func() { + dsl.Result(shared) + dsl.HTTP(func() { + dsl.GET("/") + }) + }) + }) + } +} diff --git a/http/codegen/openapi/v2/builder.go b/http/codegen/openapi/v2/builder.go index 91955703ec..e4aeaee384 100644 --- a/http/codegen/openapi/v2/builder.go +++ b/http/codegen/openapi/v2/builder.go @@ -1,3 +1,5 @@ +// This file builds Swagger 2.0 operations and schemas from HTTP endpoints. It +// uses the request or response being described to choose each example value. package openapiv2 import ( @@ -17,6 +19,21 @@ import ( // NewV2 returns the OpenAPI v2 specification for the given API. func NewV2(root *expr.RootExpr, h *expr.HostExpr) (*V2, error) { + if root == nil { + return nil, nil + } + return NewV2WithValues( + root, + h, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) +} + +// NewV2WithValues returns the OpenAPI v2 specification using values in place +// of matching titles, descriptions, and examples from the evaluated design. +// The generator supplies examples for attributes that have no matching value. +func NewV2WithValues(root *expr.RootExpr, h *expr.HostExpr, generator *expr.ExampleGenerator, values openapi.Values) (*V2, error) { if root == nil { return nil, nil } @@ -31,12 +48,23 @@ func NewV2(root *expr.RootExpr, h *expr.HostExpr) (*V2, error) { if !openapi.MustGenerate(root.API.Servers[0].Meta) || !openapi.MustGenerate(h.Meta) { host = "" } + schemas := newSchemaBuilder(values) + var contact *expr.ContactExpr + if root.API.Contact != nil { + contactCopy := *root.API.Contact + contact = &contactCopy + } + var license *expr.LicenseExpr + if root.API.License != nil { + licenseCopy := *root.API.License + license = &licenseCopy + } basePath := root.API.HTTP.Path if hasAbsoluteRoutes(root) { basePath = "" } - params := paramsFromExpr(nil, root.API.HTTP.Params, basePath) + params := paramsFromExpr(nil, root.API.HTTP.Params, basePath, values) var paramMap map[string]*Parameter if len(params) > 0 { paramMap = make(map[string]*Parameter, len(params)) @@ -47,23 +75,23 @@ func NewV2(root *expr.RootExpr, h *expr.HostExpr) (*V2, error) { s := &V2{ Swagger: "2.0", Info: &Info{ - Title: root.API.Title, - Description: root.API.Description, + Title: values.Title(root.API, root.API.Title), + Description: values.Description(root.API, root.API.Description), TermsOfService: root.API.TermsOfService, - Contact: root.API.Contact, - License: root.API.License, + Contact: contact, + License: license, Version: root.API.Version, Extensions: openapi.ExtensionsFromExpr(root.API.Meta), }, Host: host, BasePath: basePath, Paths: make(map[string]any), - Consumes: root.API.HTTP.Consumes, - Produces: root.API.HTTP.Produces, + Consumes: slices.Clone(root.API.HTTP.Consumes), + Produces: slices.Clone(root.API.HTTP.Produces), Parameters: paramMap, Tags: tags, - SecurityDefinitions: securitySpecFromExpr(root), - ExternalDocs: openapi.DocsFromExpr(root.API.Docs, root.API.Meta), + SecurityDefinitions: securitySpecFromExpr(root, values), + ExternalDocs: openapi.DocsFromExprWithValues(root.API.Docs, root.API.Meta, values), } for _, res := range root.API.HTTP.Services { if !openapi.MustGenerate(res.Meta) || !openapi.MustGenerate(res.ServiceExpr.Meta) { @@ -74,24 +102,23 @@ func NewV2(root *expr.RootExpr, h *expr.HostExpr) (*V2, error) { if !openapi.MustGenerate(fs.Meta) || !openapi.MustGenerate(fs.Service.Meta) { continue } - buildPathFromFileServer(s, root, fs) + buildPathFromFileServer(s, root, fs, schemas, generator, values) } for _, a := range res.HTTPEndpoints { if !openapi.MustGenerate(a.Meta) || !openapi.MustGenerate(a.MethodExpr.Meta) { continue } for _, route := range a.Routes { - buildPathFromExpr(s, root, h, route, basePath) + buildPathFromExpr(s, root, h, route, basePath, schemas, generator, values) } } } - if len(openapi.Definitions) > 0 { - s.Definitions = make(map[string]*openapi.Schema) - for n, d := range openapi.Definitions { - // sad but swagger doesn't support these + if len(schemas.definitions) > 0 { + s.Definitions = schemas.definitions + for _, d := range schemas.definitions { + // Swagger 2.0 does not support media metadata or schema links. d.Media = nil d.Links = nil - s.Definitions[n] = d } } // Convert OpenAPI 3.0 references (#/$defs/) to Swagger 2.0 format (#/definitions/) @@ -140,14 +167,20 @@ func addScopeDescription(scopes []*expr.ScopeExpr, sd *SecurityDefinition) { // securitySpecFromExpr generates the OpenAPI security definitions from the // security design. -func securitySpecFromExpr(root *expr.RootExpr) map[string]*SecurityDefinition { +func securitySpecFromExpr(root *expr.RootExpr, values openapi.Values) map[string]*SecurityDefinition { sds := make(map[string]*SecurityDefinition) for _, svc := range root.API.HTTP.Services { + if !openapi.MustGenerate(svc.Meta) || !openapi.MustGenerate(svc.ServiceExpr.Meta) { + continue + } for _, e := range svc.HTTPEndpoints { + if !openapi.MustGenerate(e.Meta) || !openapi.MustGenerate(e.MethodExpr.Meta) { + continue + } for _, req := range e.Requirements { for _, s := range req.Schemes { sd := SecurityDefinition{ - Description: s.Description, + Description: values.Description(s.AuthoredScheme(), s.Description), Extensions: openapi.ExtensionsFromExpr(s.Meta), } @@ -270,7 +303,7 @@ func summaryFromMeta(name string, meta expr.MetaExpr) string { return name } -func paramsFromExpr(endpoint *expr.HTTPEndpointExpr, params *expr.MappedAttributeExpr, path string) []*Parameter { +func paramsFromExpr(endpoint *expr.HTTPEndpointExpr, params *expr.MappedAttributeExpr, path string, values openapi.Values) []*Parameter { if params == nil { return nil } @@ -287,14 +320,14 @@ func paramsFromExpr(endpoint *expr.HTTPEndpointExpr, params *expr.MappedAttribut if endpoint != nil && in != "path" && openapiinternal.IsSecurityParameter(endpoint, in, pn) { return nil } - param := paramFor(at, pn, in, required) + param := paramFor(at, pn, in, required, values) res = append(res, param) return nil }) return res } -func paramsFromHeaders(endpoint *expr.HTTPEndpointExpr) []*Parameter { +func paramsFromHeaders(endpoint *expr.HTTPEndpointExpr, values openapi.Values) []*Parameter { var params []*Parameter expr.WalkMappedAttr(endpoint.Headers, func(name, elem string, att *expr.AttributeExpr) error { // nolint: errcheck @@ -302,21 +335,21 @@ func paramsFromHeaders(endpoint *expr.HTTPEndpointExpr) []*Parameter { return nil } required := endpoint.Headers.IsRequiredNoDefault(name) - params = append(params, paramFor(att, elem, "header", required)) + params = append(params, paramFor(att, elem, "header", required, values)) return nil }) return params } -func paramFor(at *expr.AttributeExpr, name, in string, required bool) *Parameter { +func paramFor(at *expr.AttributeExpr, name, in string, required bool, values openapi.Values) *Parameter { alias := at at = resolvedAliasAttribute(at) p := &Parameter{ In: in, Name: name, Default: openapi.ToStringMap(at.DefaultValue), - Description: at.Description, + Description: values.Description(alias.AuthoredAttribute(), at.Description), Required: required, } p.Type, p.Format = openAPITypeFormat(at) @@ -342,7 +375,7 @@ func itemsFromExpr(at *expr.AttributeExpr) *Items { return items } -func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, typeNamePrefix string) *Response { +func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, typeNamePrefix string, schemas *schemaBuilder, generator *expr.ExampleGenerator, fallbackDescription string, values openapi.Values) *Response { var schema *openapi.Schema if mt, ok := r.Body.Type.(*expr.ResultTypeExpr); ok { view := expr.DefaultView @@ -350,15 +383,19 @@ func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, view = v } schema = openapi.NewSchema() - schema.Ref = openapi.ResultTypeRefWithPrefix(root.API, mt, view, typeNamePrefix) + projection := openapi.ProjectResponseResult(mt, view) + schema.Ref = schemas.projectedResultTypeRefWithPrefix(root.API, mt, projection.Result, typeNamePrefix, generator) } else if r.Body.Type != expr.Empty { - schema = openapi.AttributeTypeSchemaWithPrefix(root.API, r.Body, typeNamePrefix) + schema = schemas.attributeTypeSchemaWithPrefix(root.API, r.Body, typeNamePrefix, generator) } if schema != nil { schema.Extensions = openapi.ExtensionsFromExpr(r.Meta) } - headers := headersFromExpr(r.Headers) - desc := r.Description + headers := headersFromExpr(r.Headers, values) + desc := values.Description(r, r.Description) + if desc == "" { + desc = fallbackDescription + } if desc == "" { desc = fmt.Sprintf("%s response.", http.StatusText(r.StatusCode)) } @@ -370,7 +407,7 @@ func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, } } -func headersFromExpr(headers *expr.MappedAttributeExpr) map[string]*Header { +func headersFromExpr(headers *expr.MappedAttributeExpr, values openapi.Values) map[string]*Header { if headers == nil { return nil } @@ -379,7 +416,7 @@ func headersFromExpr(headers *expr.MappedAttributeExpr) map[string]*Header { headerType, headerFormat := openAPITypeFormat(at) header := &Header{ Default: at.DefaultValue, - Description: at.Description, + Description: values.Description(at.AuthoredAttribute(), at.Description), Type: headerType, Format: headerFormat, } @@ -435,7 +472,7 @@ func initAttributeValidations(at *expr.AttributeExpr, def any) { initValidations(at, def) } -func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServerExpr) { +func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServerExpr, schemas *schemaBuilder, generator *expr.ExampleGenerator, values openapi.Values) { for _, path := range fs.RequestPaths { wcs := expr.ExtractHTTPWildcards(path) var param []*Parameter @@ -456,7 +493,8 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer }, } if len(wcs) > 0 { - schema := openapi.TypeSchema(root.API, expr.ErrorResult) + errgen := generator.At(expr.UserTypeExampleIdentity(expr.ErrorResult)) + schema := schemas.typeSchema(root.API, expr.ErrorResult, errgen) responses["404"] = &Response{Description: "File not found", Schema: schema} } @@ -477,9 +515,9 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer } operation := &Operation{ - Description: fs.Description, + Description: values.Description(fs, fs.Description), Summary: summaryFromMeta(fmt.Sprintf("Download %s", fs.FilePath), fs.Meta), - ExternalDocs: openapi.DocsFromExpr(fs.Docs, fs.Meta), + ExternalDocs: openapi.DocsFromExprWithValues(fs.Docs, fs.Meta, values), OperationID: operationID, Parameters: param, Responses: responses, @@ -503,7 +541,7 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer } } -func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr.RouteExpr, basePath string) { +func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr.RouteExpr, basePath string, schemas *schemaBuilder, generator *expr.ExampleGenerator, values openapi.Values) { endpoint := route.Endpoint tagNames := openapi.TagNamesFromExpr(endpoint.Meta) @@ -515,12 +553,13 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr // Remove any wildcards that is defined in path as a workaround to // https://github.com/OAI/OpenAPI-Specification/issues/291 key = expr.HTTPWildcardRegex.ReplaceAllString(key, "/{$1}") - params := paramsFromExpr(endpoint, endpoint.Params, key) - params = append(params, paramsFromHeaders(endpoint)...) + params := paramsFromExpr(endpoint, endpoint.Params, key, values) + params = append(params, paramsFromHeaders(endpoint, values)...) var produces []string responses := make(map[string]*Response, len(endpoint.Responses)) for _, r := range endpoint.Responses { + responseGenerator := generator.At(expr.ResponseBodyExampleIdentity(endpoint, r)) if endpoint.UsesWebSocket() { // A WebSocket endpoint allows at most one successful response // definition. So it is okay to change the first successful @@ -530,7 +569,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr r.StatusCode = expr.StatusSwitchingProtocols } } - resp := responseSpecFromExpr(s, root, r, endpoint.Service.Name()) + resp := responseSpecFromExpr(s, root, r, endpoint.Service.Name(), schemas, responseGenerator, "", values) responses[strconv.Itoa(r.StatusCode)] = resp if r.ContentType != "" { foundCT := slices.Contains(produces, r.ContentType) @@ -540,7 +579,13 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr } } for _, er := range endpoint.HTTPErrors { - resp := responseSpecFromExpr(s, root, er.Response, endpoint.Service.Name()) + responseGenerator := generator.At(expr.ErrorResponseBodyExampleIdentity(endpoint, er)) + errorDescription := values.Description(er.ErrorExpr, er.Description) + resp := responseSpecFromExpr(s, root, er.Response, endpoint.Service.Name(), schemas, responseGenerator, errorDescription, values) + resp.Description = er.Name + ": " + resp.Description + if example, ok := openapi.ErrorResponseExample(er.ErrorExpr, er.Response.Body, responseGenerator, values); ok { + resp.Examples = map[string]any{openapi.ResponseContentType(er.Response): example} + } responses[strconv.Itoa(er.Response.StatusCode)] = resp } @@ -557,9 +602,14 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr pp := &Parameter{ Name: endpoint.Body.Type.Name(), In: in, - Description: endpoint.Body.Description, + Description: values.Description(endpoint.Body.AuthoredAttribute(), endpoint.Body.Description), Required: true, - Schema: openapi.AttributeTypeSchemaWithPrefix(root.API, endpoint.Body, codegen.Goify(endpoint.Service.Name(), true)), + Schema: schemas.attributeTypeSchemaWithPrefix( + root.API, + endpoint.Body, + codegen.Goify(endpoint.Service.Name(), true), + generator.At(expr.RequestBodyExampleIdentity(endpoint)), + ), } params = append(params, pp) } @@ -599,7 +649,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr } } - description := endpoint.Description() + description := values.Description(endpoint.MethodExpr, endpoint.Description()) var requirements SecurityRequirements if len(endpoint.Requirements) > 0 { @@ -608,7 +658,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr for i, req := range endpoint.Requirements { requirement := make(map[string][]string) for _, s := range req.Schemes { - requirement[s.Hash()] = nil + requirement[s.Hash()] = make([]string, 0) switch s.Kind { case expr.OAuth2Kind: if len(req.Scopes) > 0 { @@ -638,7 +688,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr Tags: tagNames, Description: description, Summary: summaryFromExpr(endpoint.Name()+" "+endpoint.Service.Name(), endpoint, root.API.Meta), - ExternalDocs: openapi.DocsFromExpr(endpoint.MethodExpr.Docs, endpoint.MethodExpr.Meta), + ExternalDocs: openapi.DocsFromExprWithValues(endpoint.MethodExpr.Docs, endpoint.MethodExpr.Meta, values), OperationID: operationID, Parameters: params, Consumes: consumes, diff --git a/http/codegen/openapi/v2/builder_test.go b/http/codegen/openapi/v2/builder_test.go index ad5114df41..123cef057f 100644 --- a/http/codegen/openapi/v2/builder_test.go +++ b/http/codegen/openapi/v2/builder_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI v2 construction from evaluated HTTP endpoint +// designs, including request and response example ownership. package openapiv2 import ( @@ -8,9 +10,36 @@ import ( "goa.design/goa/v3/codegen" dsl "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" "gopkg.in/yaml.v3" ) +func TestNewV2WithValues(t *testing.T) { + root := codegen.RunDSL(t, localizedValuesDSL) + service := root.Service("messages") + method := service.Method("show") + values := (openapi.Values{}). + WithTitle(root.API, "Localized API"). + WithDescription(root.API, "Localized API description"). + WithDescription(service, "Localized service description"). + WithDescription(method, "Localized method description") + + spec, err := NewV2WithValues( + root, + root.API.Servers[0].Hosts[0], + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + require.NoError(t, err) + require.Equal(t, "Localized API", spec.Info.Title) + require.Equal(t, "Localized API description", spec.Info.Description) + operation := spec.Paths["/messages"].(*Path).Get + require.Equal(t, "Localized method description", operation.Description) + require.Contains(t, operation.Tags, "messages") + require.Equal(t, "Original API", root.API.Title) + require.Equal(t, "Original method description", method.Description) +} + func TestBuildPathFromFileServer(t *testing.T) { cases := []struct { path string @@ -36,7 +65,7 @@ func TestBuildPathFromFileServer(t *testing.T) { } root := &expr.RootExpr{ API: &expr.APIExpr{ - ExampleGenerator: expr.NewRandom("test"), + RandomizerFactory: expr.NewFakerRandomizerFactory("test"), }, } fs := &expr.HTTPFileServerExpr{ @@ -47,7 +76,7 @@ func TestBuildPathFromFileServer(t *testing.T) { }, RequestPaths: []string{tc.path}, } - buildPathFromFileServer(s, root, fs) + buildPathFromFileServer(s, root, fs, newSchemaBuilder(openapi.Values{}), expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) for actual := range s.Paths { if actual != tc.expected { t.Errorf("got %#v, expected %#v", actual, tc.expected) @@ -193,6 +222,19 @@ func TestOperationSecurityMarshal(t *testing.T) { } } +func TestSecurityDefinitionsIncludeVisibleOperationsOnly(t *testing.T) { + root := codegen.RunDSL(t, visibleSecuritySchemesDSL) + spec, err := NewV2(root, root.API.Servers[0].Hosts[0]) + require.NoError(t, err) + + visible := root.Service("visible").Method("read").Requirements[0].Schemes[0].Hash() + hiddenMethod := root.Service("mixed").Method("hidden").Requirements[0].Schemes[0].Hash() + hiddenService := root.Service("hidden").Method("read").Requirements[0].Schemes[0].Hash() + require.Contains(t, spec.SecurityDefinitions, visible) + require.NotContains(t, spec.SecurityDefinitions, hiddenMethod) + require.NotContains(t, spec.SecurityDefinitions, hiddenService) +} + var noSecurityOverridesAPISecurityDSL = func() { var JWTAuth = dsl.JWTSecurity("jwt") @@ -219,6 +261,66 @@ var noSecurityOverridesAPISecurityDSL = func() { }) } +var localizedValuesDSL = func() { + dsl.API("messages", func() { + dsl.Title("Original API") + dsl.Description("Original API description") + }) + dsl.Service("messages", func() { + dsl.Description("Original service description") + dsl.Method("show", func() { + dsl.Description("Original method description") + dsl.HTTP(func() { + dsl.GET("/messages") + }) + }) + }) +} + +var visibleSecuritySchemesDSL = func() { + var ( + VisibleAuth = dsl.JWTSecurity("visible_auth") + HiddenMethodAuth = dsl.JWTSecurity("hidden_method_auth") + HiddenServiceAuth = dsl.JWTSecurity("hidden_service_auth") + ) + + dsl.Service("visible", func() { + dsl.Method("read", func() { + dsl.Security(VisibleAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/visible") + }) + }) + }) + dsl.Service("mixed", func() { + dsl.Method("hidden", func() { + dsl.Meta("openapi:generate", "false") + dsl.Security(HiddenMethodAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/hidden-method") + }) + }) + }) + dsl.Service("hidden", func() { + dsl.Meta("openapi:generate", "false") + dsl.Method("read", func() { + dsl.Security(HiddenServiceAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/hidden-service") + }) + }) + }) +} + var noSecurityOverridesServiceSecurityDSL = func() { var JWTAuth = dsl.JWTSecurity("jwt") @@ -322,13 +424,17 @@ func TestBuildPathFromExpr(t *testing.T) { Meta: expr.MetaExpr{}, }, } + route.Endpoint.MethodExpr.Name = "method" + route.Endpoint.Service.ServiceExpr.Name = "service" + route.Endpoint.MethodExpr.Service = route.Endpoint.Service.ServiceExpr if tc.deprecated { route.Endpoint.Meta["openapi:deprecated"] = []string{"true"} } basePath := "/" - buildPathFromExpr(s, root, h, route, basePath) + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) + buildPathFromExpr(s, root, h, route, basePath, newSchemaBuilder(openapi.Values{}), generator, openapi.Values{}) for _, path := range s.Paths { actual := path.(*Path).Post if len(actual.Consumes) != len(tc.expected.Consumes) { diff --git a/http/codegen/openapi/v2/description_ownership_test.go b/http/codegen/openapi/v2/description_ownership_test.go new file mode 100644 index 0000000000..c10e3028ec --- /dev/null +++ b/http/codegen/openapi/v2/description_ownership_test.go @@ -0,0 +1,52 @@ +// This file verifies that shared OpenAPI v2 definitions use the named Goa +// type description instead of text from one response that uses the type. +package openapiv2 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestSharedErrorDefinitionDescription(t *testing.T) { + cases := []struct { + name string + dsl func() + description string + }{ + {"method order", testdata.SharedErrorDescriptionDSL, "Shared error value"}, + {"reversed method order", testdata.ReversedSharedErrorDescriptionDSL, "Shared error value"}, + {"undescribed type", testdata.UndescribedSharedErrorDSL, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := codegen.RunDSL(t, tc.dsl) + spec, err := NewV2( + root, + root.API.Servers[0].Hosts[0], + ) + require.NoError(t, err) + require.Equal(t, tc.description, spec.Definitions["SharedError"].Description) + }) + } +} + +func TestSharedErrorDefinitionLocalizedDescription(t *testing.T) { + root := codegen.RunDSL(t, testdata.SharedErrorDescriptionDSL) + sharedError := root.UserType("SharedError") + values := (openapi.Values{}).WithDescription(sharedError.Attribute(), "Localized shared error") + + spec, err := NewV2WithValues( + root, + root.API.Servers[0].Hosts[0], + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + require.NoError(t, err) + require.Equal(t, "Localized shared error", spec.Definitions["SharedError"].Description) +} diff --git a/http/codegen/openapi/v2/files.go b/http/codegen/openapi/v2/files.go index a542fd9e49..7c43d2174a 100644 --- a/http/codegen/openapi/v2/files.go +++ b/http/codegen/openapi/v2/files.go @@ -1,3 +1,5 @@ +// This file builds Swagger 2.0 JSON and YAML files from one HTTP design. Each +// example comes from the request or response described in the file. package openapiv2 import ( @@ -10,7 +12,19 @@ import ( // path is the output path of the files relative to the gen directory, without // extension. func Files(root *expr.RootExpr, path string) ([]*codegen.File, error) { - spec, err := NewV2(root, root.API.Servers[0].Hosts[0]) + return FilesWithValues( + root, + path, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) +} + +// FilesWithValues returns Swagger 2.0 files using values in place of matching +// titles, descriptions, and examples from the evaluated design. The generator +// supplies examples for attributes that have no matching value. +func FilesWithValues(root *expr.RootExpr, path string, generator *expr.ExampleGenerator, values openapi.Values) ([]*codegen.File, error) { + spec, err := NewV2WithValues(root, root.API.Servers[0].Hosts[0], generator, values) if err != nil { return nil, err } diff --git a/http/codegen/openapi/v2/files_test.go b/http/codegen/openapi/v2/files_test.go index 0846eaac00..52bb5247a5 100644 --- a/http/codegen/openapi/v2/files_test.go +++ b/http/codegen/openapi/v2/files_test.go @@ -1,3 +1,5 @@ +// This file renders complete Swagger 2.0 documents from prepared HTTP designs +// and compares the JSON and YAML output produced with run-owned example state. package openapiv2_test import ( @@ -33,6 +35,7 @@ func TestSections(t *testing.T) { {"multiple-services", testdata.MultipleServicesDSL}, {"multiple-views", testdata.MultipleViewsDSL}, {"explicit-view", testdata.ExplicitViewDSL}, + {"released-response-collection-names", testdata.ReleasedResponseCollectionNamesDSL}, {"security", testdata.SecurityDSL}, {"server-host-with-variables", testdata.ServerHostWithVariablesDSL}, {"with-spaces", testdata.WithSpacesDSL}, @@ -52,11 +55,11 @@ func TestSections(t *testing.T) { {"additional-properties-type", testdata.AdditionalPropertiesTypeDSL}, {"additional-properties-payload-result", testdata.AdditionalPropertiesPayloadResultDSL}, {"additional-properties-embedded-payload-result", testdata.AdditionalPropertiesPayloadResultDSL}, + {"error-examples", testdata.ErrorExamplesDSL}, + {"shared-error-description", testdata.SharedErrorDescriptionDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) oFiles, err := openapiv2.Files(root, openapi.DefaultPath20) if err != nil { @@ -112,8 +115,6 @@ func TestValidations(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) oFiles, err := openapiv2.Files(root, openapi.DefaultPath20) require.NoError(t, err, "OpenAPI failed") @@ -156,8 +157,6 @@ func TestExtensions(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) oFiles, err := openapiv2.Files(root, openapi.DefaultPath20) require.NoError(t, err, "OpenAPI failed") @@ -189,9 +188,6 @@ func TestExtensions(t *testing.T) { } func TestNamedPrimitiveParamsAndHeadersUseOpenAPIBaseTypes(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) - root := expr.RunDSL(t, func() { var UUID = dsl.Type("UUID", dsl.String, func() { dsl.Format(dsl.FormatUUID) diff --git a/http/codegen/openapi/v2/json_schema.go b/http/codegen/openapi/v2/json_schema.go new file mode 100644 index 0000000000..cc85615c9f --- /dev/null +++ b/http/codegen/openapi/v2/json_schema.go @@ -0,0 +1,306 @@ +// This file builds the JSON schemas placed in one Swagger 2.0 document. Each +// build keeps its definitions and assigned type names in its own builder. +package openapiv2 + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" +) + +type ( + // schemaBuilder builds every schema used by one Swagger document. + schemaBuilder struct { + definitions map[string]*openapi.Schema + definitionNames map[*expr.ResultTypeExpr]string + values openapi.Values + } +) + +// newSchemaBuilder starts a schema build with no definitions or assigned names. +func newSchemaBuilder(values openapi.Values) *schemaBuilder { + return &schemaBuilder{ + definitions: make(map[string]*openapi.Schema), + definitionNames: make(map[*expr.ResultTypeExpr]string), + values: values, + } +} + +// BuildAttributeSchema returns the JSON schema for at. The returned schema +// includes every named definition referenced by at. +func BuildAttributeSchema(api *expr.APIExpr, at *expr.AttributeExpr, generator *expr.ExampleGenerator) *openapi.Schema { + builder := newSchemaBuilder(openapi.Values{}) + schema := builder.attributeTypeSchemaWithPrefix(api, at, "", generator) + if len(builder.definitions) > 0 { + schema.Defs = builder.definitions + } + return schema +} + +// resultTypeRefWithPrefix returns a reference to the requested result view. It +// adds the definition to this builder the first time the result is used. +func (b *schemaBuilder) resultTypeRefWithPrefix(api *expr.APIExpr, mt *expr.ResultTypeExpr, view, prefix string, gen *expr.ExampleGenerator) string { + projected, err := expr.Project(mt, view) + if err != nil { + panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug + } + return b.projectedResultTypeRefWithPrefix(api, mt, projected, prefix, gen) +} + +// projectedResultTypeRefWithPrefix adds a result type that was already +// projected for one HTTP response. +func (b *schemaBuilder) projectedResultTypeRefWithPrefix(api *expr.APIExpr, source, projected *expr.ResultTypeExpr, prefix string, gen *expr.ExampleGenerator) string { + var metaName string + if n, ok := source.Meta["openapi:typename"]; ok { + metaName = codegen.Goify(n[0], true) + } + name := projected.TypeName + if metaName != "" { + name = metaName + } + if assigned, ok := b.definitionNames[projected]; ok { + // expr.Project can return the original result type. Reuse the name chosen + // when this build first saw that result. + name = assigned + } else { + if _, ok := b.definitions[name]; !ok { + name = codegen.Goify(prefix, true) + codegen.Goify(name, true) + if metaName != "" { + name = metaName + } + } + if projected == source { + // Keep the chosen name here instead of changing the design result. + b.definitionNames[projected] = name + } + } + if _, ok := b.definitions[name]; !ok { + b.generateResultTypeDefinition(api, renamedResultType(projected, name), expr.DefaultView, gen) + } + return fmt.Sprintf("#/$defs/%s", name) +} + +// typeRefWithPrefix returns a reference to a user type. It adds the definition +// to this builder the first time the type is used. +func (b *schemaBuilder) typeRefWithPrefix(api *expr.APIExpr, ut *expr.UserTypeExpr, prefix string, gen *expr.ExampleGenerator) string { + typeName := ut.TypeName + if prefix != "" { + typeName = codegen.Goify(prefix, true) + codegen.Goify(ut.TypeName, true) + } + if n, ok := ut.Meta["openapi:typename"]; ok { + typeName = codegen.Goify(n[0], true) + } + if _, ok := b.definitions[typeName]; !ok { + b.generateTypeDefinitionWithName(api, ut, typeName, gen) + } + return fmt.Sprintf("#/$defs/%s", typeName) +} + +// generateResultTypeDefinition adds the requested result view unless this +// build already has a definition with the same name. +func (b *schemaBuilder) generateResultTypeDefinition(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, gen *expr.ExampleGenerator) { + if _, ok := b.definitions[mt.TypeName]; ok { + return + } + schema := openapi.NewSchema() + schema.Title = fmt.Sprintf("Mediatype identifier: %s", mt.Identifier) + b.definitions[mt.TypeName] = schema + b.buildResultTypeSchema(api, mt, view, schema, gen) +} + +// generateTypeDefinitionWithName adds the user type under typeName unless this +// build already has a definition with that name. +func (b *schemaBuilder) generateTypeDefinitionWithName(api *expr.APIExpr, ut *expr.UserTypeExpr, typeName string, gen *expr.ExampleGenerator) { + if _, ok := b.definitions[typeName]; ok { + return + } + schema := openapi.NewSchema() + schema.Title = typeName + b.definitions[typeName] = schema + b.buildAttributeSchema(api, schema, ut.AttributeExpr, gen.At(expr.UserTypeExampleIdentity(ut))) +} + +// typeSchema builds a schema for t and adds any named definitions it uses to +// this builder. +func (b *schemaBuilder) typeSchema(api *expr.APIExpr, t expr.DataType, gen *expr.ExampleGenerator) *openapi.Schema { + return b.typeSchemaWithPrefix(api, t, "", gen) +} + +// typeSchemaWithPrefix builds a schema for t and adds prefix to new named +// definitions created while walking the type. +func (b *schemaBuilder) typeSchemaWithPrefix(api *expr.APIExpr, t expr.DataType, prefix string, gen *expr.ExampleGenerator) *openapi.Schema { + schema := openapi.NewSchema() + switch actual := t.(type) { + case expr.Primitive: + schema.Type = openapi.Type(actual.Name()) + switch actual.Kind() { + case expr.AnyKind: + // Leaving the type empty allows every JSON value. + schema.Type = openapi.Type("") + case expr.IntKind, expr.Int64Kind, + expr.UIntKind, expr.UInt64Kind: + schema.Type = openapi.Integer + schema.Format = "int64" + case expr.Int32Kind, expr.UInt32Kind: + schema.Type = openapi.Integer + schema.Format = "int32" + case expr.Float32Kind: + schema.Type = openapi.Number + schema.Format = "float" + case expr.Float64Kind: + schema.Type = openapi.Number + schema.Format = "double" + case expr.BytesKind: + schema.Type = openapi.String + schema.Format = "byte" + } + case *expr.Array: + schema.Type = openapi.Array + schema.Items = openapi.NewSchema() + b.buildAttributeSchema(api, schema.Items, actual.ElemType, gen.ArrayElement(0)) + case *expr.Object: + schema.Type = openapi.Object + for _, nat := range *actual { + if !openapi.MustGenerate(nat.Attribute.Meta) { + continue + } + property := openapi.NewSchema() + b.buildAttributeSchema(api, property, nat.Attribute, gen.Member(nat.Name)) + schema.Properties[nat.Name] = property + } + case *expr.Map: + schema.Type = openapi.Object + if actual.KeyType.Type == expr.String && actual.ElemType.Type != expr.Any { + value := openapi.NewSchema() + schema.AdditionalProperties = b.buildAttributeSchema(api, value, actual.ElemType, gen.MapValue(0)) + } else { + schema.AdditionalProperties = true + } + case *expr.Union: + typeKey := actual.GetTypeKey() + valueKey := actual.GetValueKey() + schema.Type = openapi.Object + for _, val := range actual.Values { + valueSchema := b.typeSchemaWithPrefix(api, val.Attribute.Type, prefix, gen.UnionMember(val.Name)) + initSchemaValidation(valueSchema, val.Attribute) + schema.AnyOf = append(schema.AnyOf, &openapi.Schema{ + Type: openapi.Object, + Properties: map[string]*openapi.Schema{ + typeKey: { + Type: openapi.String, + Enum: []any{val.Name}, + }, + valueKey: valueSchema, + }, + Required: []string{typeKey, valueKey}, + }) + } + case *expr.UserTypeExpr: + if expr.IsAlias(actual) { + schema = b.typeSchemaWithPrefix(api, actual.Attribute().Type, prefix, gen.At(expr.UserTypeExampleIdentity(actual))) + initSchemaValidation(schema, actual.Attribute()) + break + } + schema.Ref = b.typeRefWithPrefix(api, actual, prefix, gen) + case *expr.ResultTypeExpr: + schema.Ref = b.resultTypeRefWithPrefix(api, actual, expr.DefaultView, prefix, gen) + } + return schema +} + +// attributeTypeSchemaWithPrefix builds a schema for at, including its +// validation rules, and adds prefix to new named definitions. +func (b *schemaBuilder) attributeTypeSchemaWithPrefix(api *expr.APIExpr, at *expr.AttributeExpr, prefix string, gen *expr.ExampleGenerator) *openapi.Schema { + schema := b.typeSchemaWithPrefix(api, at.Type, prefix, gen) + initSchemaValidation(schema, at) + return schema +} + +// buildAttributeSchema fills schema with the type, example, description, and +// validation rules from at. +func (b *schemaBuilder) buildAttributeSchema(api *expr.APIExpr, schema *openapi.Schema, at *expr.AttributeExpr, gen *expr.ExampleGenerator) *openapi.Schema { + schema.Merge(b.typeSchemaWithPrefix(api, at.Type, "", gen)) + if schema.Ref != "" { + return schema + } + schema.DefaultValue = openapi.ToStringMap(at.DefaultValue) + if description := b.values.Description(at.AuthoredAttribute(), at.Description); description != "" { + schema.Description = description + } + schema.Example = openapi.ProjectExample(at, b.values.Example(at, gen)) + schema.Extensions = openapi.ExtensionsFromExpr(at.Meta) + if additional := openapi.AdditionalPropertiesFromExpr(at.Meta); additional != nil { + schema.AdditionalProperties = additional + } + initSchemaValidation(schema, at) + return schema +} + +// initSchemaValidation copies the validation rules from at into schema. +func initSchemaValidation(schema *openapi.Schema, at *expr.AttributeExpr) { + validation := at.Validation + if validation == nil { + return + } + schema.Enum = validation.Values + if validation.Format != "" { + schema.Format = string(validation.Format) + } + schema.Pattern = validation.Pattern + if validation.ExclusiveMinimum != nil { + schema.ExclusiveMinimum = validation.ExclusiveMinimum + } + if validation.Minimum != nil { + schema.Minimum = validation.Minimum + } + if validation.ExclusiveMaximum != nil { + schema.ExclusiveMaximum = validation.ExclusiveMaximum + } + if validation.Maximum != nil { + schema.Maximum = validation.Maximum + } + if validation.MinLength != nil { + if _, ok := at.Type.(*expr.Array); ok { + schema.MinItems = validation.MinLength + } else { + schema.MinLength = validation.MinLength + } + } + if validation.MaxLength != nil { + if _, ok := at.Type.(*expr.Array); ok { + schema.MaxItems = validation.MaxLength + } else { + schema.MaxLength = validation.MaxLength + } + } + for _, name := range validation.Required { + if attribute := at.Find(name); attribute != nil && !openapi.MustGenerate(attribute.Meta) { + continue + } + schema.Required = append(schema.Required, name) + } +} + +// renamedResultType returns rt with name without changing the design result. +func renamedResultType(rt *expr.ResultTypeExpr, name string) *expr.ResultTypeExpr { + if rt.TypeName == name { + return rt + } + userType := *rt.UserTypeExpr + userType.TypeName = name + result := *rt + result.UserTypeExpr = &userType + return &result +} + +// buildResultTypeSchema fills schema with the requested result view. +func (b *schemaBuilder) buildResultTypeSchema(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, schema *openapi.Schema, gen *expr.ExampleGenerator) { + schema.Media = &openapi.Media{Type: mt.Identifier} + projected, err := expr.Project(mt, view) + if err != nil { + panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug + } + b.buildAttributeSchema(api, schema, projected.AttributeExpr, gen.At(expr.UserTypeExampleIdentity(projected))) +} diff --git a/http/codegen/openapi/v2/json_schema_union_test.go b/http/codegen/openapi/v2/json_schema_union_test.go new file mode 100644 index 0000000000..4c8d1d434d --- /dev/null +++ b/http/codegen/openapi/v2/json_schema_union_test.go @@ -0,0 +1,116 @@ +// This file checks that each Swagger union choice pairs its name with the +// schema for the matching value. +package openapiv2 + +import ( + "encoding/json" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" +) + +func TestAttributeTypeSchemaCorrelatesUnionDiscriminatorAndValue(t *testing.T) { + method := &expr.MethodExpr{Name: "union", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(method), + ) + schema := newSchemaBuilder(openapi.Values{}).attributeTypeSchemaWithPrefix( + &expr.APIExpr{}, + unionAttribute(), + "", + generator, + ) + + require.Len(t, schema.AnyOf, 2) + assertUnionSchemaBranch(t, schema.AnyOf[0], "text", openapi.String) + assertUnionSchemaBranch(t, schema.AnyOf[1], "count", openapi.Integer) + assert.Empty(t, schema.Properties) +} + +func TestBuildAttributeSchemaKeepsDefinitionsSeparate(t *testing.T) { + type result struct { + field string + schema *openapi.Schema + } + start := make(chan struct{}) + results := make(chan result, 2) + var ready sync.WaitGroup + ready.Add(2) + build := func(field string) { + ready.Done() + <-start + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(field)) + results <- result{ + field: field, + schema: BuildAttributeSchema( + &expr.APIExpr{}, + namedObjectAttribute(field), + generator, + ), + } + } + go build("first") + go build("second") + ready.Wait() + close(start) + + for range 2 { + built := <-results + require.Equal(t, "#/$defs/Shared", built.schema.Ref) + require.Contains(t, built.schema.Defs, "Shared") + require.Contains(t, built.schema.Defs["Shared"].Properties, built.field) + other := "first" + if built.field == "first" { + other = "second" + } + require.NotContains(t, built.schema.Defs["Shared"].Properties, other) + _, err := json.Marshal(built.schema) + require.NoError(t, err) + } +} + +// assertUnionSchemaBranch checks one generated union branch's tag, required +// fields, and value type. +func assertUnionSchemaBranch(t *testing.T, branch *openapi.Schema, tag string, valueType openapi.Type) { + t.Helper() + assert.Equal(t, openapi.Type(openapi.Object), branch.Type) + assert.Equal(t, []string{"type", "value"}, branch.Required) + require.Contains(t, branch.Properties, "type") + assert.Equal(t, []any{tag}, branch.Properties["type"].Enum) + require.Contains(t, branch.Properties, "value") + assert.Equal(t, valueType, branch.Properties["value"].Type) +} + +// unionAttribute returns the string-or-integer union used by these schema tests. +func unionAttribute() *expr.AttributeExpr { + return &expr.AttributeExpr{ + Type: &expr.Union{ + TypeName: "outcome", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "count", Attribute: &expr.AttributeExpr{Type: expr.Int}}, + }, + }, + } +} + +// namedObjectAttribute returns a named object with one field. +func namedObjectAttribute(field string) *expr.AttributeExpr { + object := expr.Object{ + &expr.NamedAttributeExpr{ + Name: field, + Attribute: &expr.AttributeExpr{Type: expr.String}, + }, + } + return &expr.AttributeExpr{ + Type: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &object}, + TypeName: "Shared", + }, + } +} diff --git a/http/codegen/openapi/v2/openapi.go b/http/codegen/openapi/v2/openapi.go index 828d575689..49b3ed3371 100644 --- a/http/codegen/openapi/v2/openapi.go +++ b/http/codegen/openapi/v2/openapi.go @@ -164,6 +164,8 @@ type ( Schema *openapi.Schema `json:"schema,omitempty" yaml:"schema,omitempty"` // Headers is a list of headers that are sent with the response. Headers map[string]*Header `json:"headers,omitempty" yaml:"headers,omitempty"` + // Examples contains one response body example for each content type. + Examples map[string]any `json:"examples,omitempty" yaml:"examples,omitempty"` // Ref references a global API response. // This field is exclusive with the other fields of Response. Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` diff --git a/http/codegen/openapi/v2/public_api_test.go b/http/codegen/openapi/v2/public_api_test.go new file mode 100644 index 0000000000..a76d66ad7b --- /dev/null +++ b/http/codegen/openapi/v2/public_api_test.go @@ -0,0 +1,100 @@ +// This file protects the released OpenAPI v2 function signatures and checks +// that their default files match files produced with the root's example +// generator and no replacement values. +package openapiv2_test + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + openapiv2 "goa.design/goa/v3/http/codegen/openapi/v2" +) + +var ( + _ func(*expr.RootExpr, *expr.HostExpr) (*openapiv2.V2, error) = openapiv2.NewV2 + _ func(*expr.RootExpr, string) ([]*codegen.File, error) = openapiv2.Files + + facadeDSL = func() { + dsl.API("facade", func() { + dsl.Server("facade", func() { + dsl.Host("localhost", func() { + dsl.URI("https://goa.design") + }) + }) + }) + dsl.Service("facade", func() { + dsl.Method("show", func() { + dsl.Payload(func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Result(func() { + dsl.Attribute("answer", dsl.String) + }) + dsl.HTTP(func() { + dsl.POST("/items") + }) + }) + }) + } +) + +func TestDefaultFacadeMatchesWithValues(t *testing.T) { + root := expr.RunDSL(t, facadeDSL) + root.API.RandomizerFactory = expr.NewFakerRandomizerFactory("released facade") + host := root.API.Servers[0].Hosts[0] + + gotSpec, err := openapiv2.NewV2(root, host) + require.NoError(t, err) + wantSpec, err := openapiv2.NewV2WithValues( + root, + host, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) + require.NoError(t, err) + require.Equal(t, wantSpec, gotSpec) + otherSpec, err := openapiv2.NewV2WithValues( + root, + host, + expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("other")), + openapi.Values{}, + ) + require.NoError(t, err) + require.NotEqual(t, otherSpec, gotSpec) + + gotFiles, err := openapiv2.Files(root, openapi.DefaultPath20) + require.NoError(t, err) + wantFiles, err := openapiv2.FilesWithValues( + root, + openapi.DefaultPath20, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) + require.NoError(t, err) + require.Equal(t, renderFiles(t, wantFiles), renderFiles(t, gotFiles)) +} + +// renderFiles runs each file template so the test compares the documents that +// users receive instead of comparing template implementation details. +func renderFiles(t *testing.T, files []*codegen.File) map[string]string { + t.Helper() + + rendered := make(map[string]string, len(files)) + for _, file := range files { + var buf bytes.Buffer + for _, section := range file.SectionTemplates { + tmpl, err := template.New("openapi").Funcs(section.FuncMap).Parse(section.Source) + require.NoError(t, err) + require.NoError(t, tmpl.Execute(&buf, section.Data)) + } + rendered[file.Path] = buf.String() + } + return rendered +} diff --git a/http/codegen/openapi/v2/testdata/TestSections/error-examples_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/error-examples_file0.golden new file mode 100644 index 0000000000..a5a71ad4ed --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/error-examples_file0.golden @@ -0,0 +1,472 @@ +{ + "consumes": [ + "application/json", + "application/xml", + "application/gob" + ], + "definitions": { + "ErrorsErrorBadRequestResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": true, + "timeout": false + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": true, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": false, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorDeadlineResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": true, + "timeout": true + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": true, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": true, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorInternalResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": false, + "timeout": false + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": false, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": false, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorNotFoundResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": true, + "timeout": true + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": true, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": true, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorRetryDeadlineResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": true, + "timeout": true + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": true, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": true, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorRetryResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": false, + "timeout": true + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": false, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": true, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "GoaCustomError": { + "description": "Error_custom_Response_Body result type (default view)", + "example": { + "message": "error message", + "name": "custom" + }, + "properties": { + "message": { + "example": "error message", + "type": "string" + }, + "name": { + "example": "custom", + "type": "string" + } + }, + "required": [ + "name", + "message" + ], + "title": "Mediatype identifier: application/vnd.goa.custom-error; view=default", + "type": "object" + } + }, + "host": "localhost:80", + "info": { + "title": "", + "version": "0.0.1" + }, + "paths": { + "/": { + "get": { + "operationId": "Errors#Error", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "description": "bad_request: Bad Request response.", + "schema": { + "$ref": "#/definitions/ErrorsErrorBadRequestResponseBody" + } + }, + "404": { + "description": "not_found: Not Found response.", + "examples": { + "application/vnd.goa.error": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "not_found", + "temporary": false, + "timeout": false + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorNotFoundResponseBody" + } + }, + "409": { + "description": "custom: Conflict response.", + "schema": { + "$ref": "#/definitions/GoaCustomError" + } + }, + "429": { + "description": "retry: Too Many Requests response.", + "examples": { + "application/vnd.goa.error": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "retry", + "temporary": true, + "timeout": false + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorRetryResponseBody" + } + }, + "500": { + "description": "internal: Internal Server Error response.", + "examples": { + "application/vnd.goa.error": { + "fault": true, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "internal", + "temporary": false, + "timeout": false + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorInternalResponseBody" + } + }, + "503": { + "description": "retry_deadline: Service Unavailable response.", + "examples": { + "application/vnd.goa.error": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "retry_deadline", + "temporary": true, + "timeout": true + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorRetryDeadlineResponseBody" + } + }, + "504": { + "description": "deadline: Gateway Timeout response.", + "examples": { + "application/vnd.goa.error": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "deadline", + "temporary": false, + "timeout": true + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorDeadlineResponseBody" + } + } + }, + "schemes": [ + "http" + ], + "summary": "Error Errors", + "tags": [ + "Errors" + ] + } + } + }, + "produces": [ + "application/json", + "application/xml", + "application/gob" + ], + "swagger": "2.0" +} diff --git a/http/codegen/openapi/v2/testdata/TestSections/error-examples_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/error-examples_file1.golden new file mode 100644 index 0000000000..440df43548 --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/error-examples_file1.golden @@ -0,0 +1,369 @@ +swagger: "2.0" +info: + title: "" + version: 0.0.1 +host: localhost:80 +consumes: + - application/json + - application/xml + - application/gob +produces: + - application/json + - application/xml + - application/gob +paths: + /: + get: + tags: + - Errors + summary: Error Errors + operationId: Errors#Error + responses: + "204": + description: No Content response. + "400": + description: 'bad_request: Bad Request response.' + schema: + $ref: '#/definitions/ErrorsErrorBadRequestResponseBody' + "404": + description: 'not_found: Not Found response.' + schema: + $ref: '#/definitions/ErrorsErrorNotFoundResponseBody' + examples: + application/vnd.goa.error: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: not_found + temporary: false + timeout: false + "409": + description: 'custom: Conflict response.' + schema: + $ref: '#/definitions/GoaCustomError' + "429": + description: 'retry: Too Many Requests response.' + schema: + $ref: '#/definitions/ErrorsErrorRetryResponseBody' + examples: + application/vnd.goa.error: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: retry + temporary: true + timeout: false + "500": + description: 'internal: Internal Server Error response.' + schema: + $ref: '#/definitions/ErrorsErrorInternalResponseBody' + examples: + application/vnd.goa.error: + fault: true + id: 123abc + message: parameter 'p' must be an integer + name: internal + temporary: false + timeout: false + "503": + description: 'retry_deadline: Service Unavailable response.' + schema: + $ref: '#/definitions/ErrorsErrorRetryDeadlineResponseBody' + examples: + application/vnd.goa.error: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: retry_deadline + temporary: true + timeout: true + "504": + description: 'deadline: Gateway Timeout response.' + schema: + $ref: '#/definitions/ErrorsErrorDeadlineResponseBody' + examples: + application/vnd.goa.error: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: deadline + temporary: false + timeout: true + schemes: + - http +definitions: + ErrorsErrorBadRequestResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: false + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorDeadlineResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorInternalResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: false + timeout: + type: boolean + description: Is the error a timeout? + example: false + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorNotFoundResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorRetryDeadlineResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorRetryResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: false + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + GoaCustomError: + title: 'Mediatype identifier: application/vnd.goa.custom-error; view=default' + type: object + properties: + message: + type: string + example: error message + name: + type: string + example: custom + description: Error_custom_Response_Body result type (default view) + example: + message: error message + name: custom + required: + - name + - message diff --git a/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file0.golden new file mode 100644 index 0000000000..c5cb00388f --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file0.golden @@ -0,0 +1,144 @@ +{ + "consumes": [ + "application/json", + "application/xml", + "application/gob" + ], + "definitions": { + "StorageStoredBottleResponseCollection": { + "description": "list_default_response_body is the result type for an array of StoredBottle (default view)", + "example": [ + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + } + ], + "items": { + "$ref": "#/definitions/StoredBottleResponse" + }, + "title": "Mediatype identifier: application/vnd.stored-bottle; type=collection; view=default", + "type": "array" + }, + "StorageStoredBottleResponseTinyCollection": { + "description": "StorageStoredBottleResponseTinyCollection is the result type for an array of StoredBottleResponseTiny (default view)", + "example": [ + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + } + ], + "items": { + "$ref": "#/definitions/StoredBottleResponseTiny" + }, + "title": "Mediatype identifier: application/vnd.stored-bottle; type=collection; view=tiny", + "type": "array" + }, + "StoredBottleResponse": { + "description": "StoredBottle result type (default view)", + "example": { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + "properties": { + "name": { + "example": "Blue's Cuvee", + "type": "string" + }, + "vintage": { + "example": 2003, + "format": "int32", + "type": "integer" + } + }, + "required": [ + "name", + "vintage" + ], + "title": "Mediatype identifier: application/vnd.stored-bottle; view=default", + "type": "object" + }, + "StoredBottleResponseTiny": { + "description": "StoredBottle result type (tiny view) (default view)", + "example": { + "name": "Blue's Cuvee" + }, + "properties": { + "name": { + "example": "Blue's Cuvee", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "Mediatype identifier: application/vnd.stored-bottle; view=default", + "type": "object" + } + }, + "host": "localhost:80", + "info": { + "title": "", + "version": "0.0.1" + }, + "paths": { + "/default": { + "get": { + "operationId": "storage#list_default", + "responses": { + "200": { + "description": "OK response.", + "schema": { + "$ref": "#/definitions/StorageStoredBottleResponseCollection" + } + } + }, + "schemes": [ + "http" + ], + "summary": "list_default storage", + "tags": [ + "storage" + ] + } + }, + "/tiny": { + "get": { + "operationId": "storage#list_tiny", + "responses": { + "200": { + "description": "OK response.", + "schema": { + "$ref": "#/definitions/StorageStoredBottleResponseTinyCollection" + } + } + }, + "schemes": [ + "http" + ], + "summary": "list_tiny storage", + "tags": [ + "storage" + ] + } + } + }, + "produces": [ + "application/json", + "application/xml", + "application/gob" + ], + "swagger": "2.0" +} diff --git a/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file1.golden new file mode 100644 index 0000000000..3a23ea386b --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file1.golden @@ -0,0 +1,94 @@ +swagger: "2.0" +info: + title: "" + version: 0.0.1 +host: localhost:80 +consumes: + - application/json + - application/xml + - application/gob +produces: + - application/json + - application/xml + - application/gob +paths: + /default: + get: + tags: + - storage + summary: list_default storage + operationId: storage#list_default + responses: + "200": + description: OK response. + schema: + $ref: '#/definitions/StorageStoredBottleResponseCollection' + schemes: + - http + /tiny: + get: + tags: + - storage + summary: list_tiny storage + operationId: storage#list_tiny + responses: + "200": + description: OK response. + schema: + $ref: '#/definitions/StorageStoredBottleResponseTinyCollection' + schemes: + - http +definitions: + StorageStoredBottleResponseCollection: + title: 'Mediatype identifier: application/vnd.stored-bottle; type=collection; view=default' + type: array + items: + $ref: '#/definitions/StoredBottleResponse' + description: list_default_response_body is the result type for an array of StoredBottle (default view) + example: + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + StorageStoredBottleResponseTinyCollection: + title: 'Mediatype identifier: application/vnd.stored-bottle; type=collection; view=tiny' + type: array + items: + $ref: '#/definitions/StoredBottleResponseTiny' + description: StorageStoredBottleResponseTinyCollection is the result type for an array of StoredBottleResponseTiny (default view) + example: + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee + StoredBottleResponse: + title: 'Mediatype identifier: application/vnd.stored-bottle; view=default' + type: object + properties: + name: + type: string + example: Blue's Cuvee + vintage: + type: integer + example: 2003 + format: int32 + description: StoredBottle result type (default view) + example: + name: Blue's Cuvee + vintage: 2003 + required: + - name + - vintage + StoredBottleResponseTiny: + title: 'Mediatype identifier: application/vnd.stored-bottle; view=default' + type: object + properties: + name: + type: string + example: Blue's Cuvee + description: StoredBottle result type (tiny view) (default view) + example: + name: Blue's Cuvee + required: + - name diff --git a/http/codegen/openapi/v2/testdata/TestSections/security_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/security_file0.golden index dae601cbf0..caf6b7a4b4 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/security_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/security_file0.golden @@ -24,9 +24,9 @@ ], "security": [ { - "api_key_query_k": null, - "basic_header_Authorization": null, - "jwt_header_X-Authorization": null, + "api_key_query_k": [], + "basic_header_Authorization": [], + "jwt_header_X-Authorization": [], "oauth2_header_Token": [ "api:read" ] @@ -49,7 +49,7 @@ ], "security": [ { - "api_key_header_Authorization": null + "api_key_header_Authorization": [] }, { "oauth2_query_auth": [ diff --git a/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file0.golden new file mode 100644 index 0000000000..21a752d9a4 --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file0.golden @@ -0,0 +1,92 @@ +{ + "consumes": [ + "application/json", + "application/xml", + "application/gob" + ], + "definitions": { + "SharedError": { + "description": "Shared error value", + "example": { + "message": "shared failure" + }, + "properties": { + "message": { + "description": "Error message", + "example": "shared failure", + "type": "string" + } + }, + "required": [ + "message" + ], + "title": "SharedError", + "type": "object" + } + }, + "host": "localhost:80", + "info": { + "title": "", + "version": "0.0.1" + }, + "paths": { + "/first": { + "get": { + "operationId": "errors#first", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "description": "first_error: First failure", + "schema": { + "$ref": "#/definitions/SharedError", + "required": [ + "message" + ] + } + } + }, + "schemes": [ + "http" + ], + "summary": "first errors", + "tags": [ + "errors" + ] + } + }, + "/second": { + "get": { + "operationId": "errors#second", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "description": "second_error: Second failure", + "schema": { + "$ref": "#/definitions/SharedError", + "required": [ + "message" + ] + } + } + }, + "schemes": [ + "http" + ], + "summary": "second errors", + "tags": [ + "errors" + ] + } + } + }, + "produces": [ + "application/json", + "application/xml", + "application/gob" + ], + "swagger": "2.0" +} diff --git a/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file1.golden new file mode 100644 index 0000000000..3287698fb0 --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file1.golden @@ -0,0 +1,62 @@ +swagger: "2.0" +info: + title: "" + version: 0.0.1 +host: localhost:80 +consumes: + - application/json + - application/xml + - application/gob +produces: + - application/json + - application/xml + - application/gob +paths: + /first: + get: + tags: + - errors + summary: first errors + operationId: errors#first + responses: + "204": + description: No Content response. + "400": + description: 'first_error: First failure' + schema: + $ref: '#/definitions/SharedError' + required: + - message + schemes: + - http + /second: + get: + tags: + - errors + summary: second errors + operationId: errors#second + responses: + "204": + description: No Content response. + "400": + description: 'second_error: Second failure' + schema: + $ref: '#/definitions/SharedError' + required: + - message + schemes: + - http +definitions: + SharedError: + title: SharedError + type: object + properties: + message: + type: string + description: Error message + example: shared failure + description: Shared error value + example: + message: shared failure + required: + - message diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden index 8a2ad8deab..290850a547 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden @@ -9,12 +9,10 @@ "example": { "any": "", "any_array": [ - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "properties": { @@ -23,8 +21,6 @@ }, "any_array": { "example": [ - "", - "", "" ], "items": { @@ -35,7 +31,7 @@ "any_map": { "additionalProperties": true, "example": { - "": "" + "key": "" }, "type": "object" } @@ -47,13 +43,10 @@ "example": { "any": "", "any_array": [ - "", - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "properties": { @@ -62,9 +55,6 @@ }, "any_array": { "example": [ - "", - "", - "", "" ], "items": { @@ -75,7 +65,7 @@ "any_map": { "additionalProperties": true, "example": { - "": "" + "key": "" }, "type": "object" } diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden index 442df6f3ea..8b2d7825be 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden @@ -44,21 +44,17 @@ definitions: example: "" example: - "" - - "" - - "" any_map: type: object example: - "": "" + key: "" additionalProperties: true example: any: "" any_array: - "" - - "" - - "" any_map: - "": "" + key: "" TestServiceTestEndpointResponseBody: title: TestServiceTestEndpointResponseBody type: object @@ -71,20 +67,14 @@ definitions: example: "" example: - "" - - "" - - "" - - "" any_map: type: object example: - "": "" + key: "" additionalProperties: true example: any: "" any_array: - "" - - "" - - "" - - "" any_map: - "": "" + key: "" diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden index ac58094d55..fa511401da 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden @@ -19,7 +19,7 @@ "type": "object" }, "GoaFoobar": { - "description": "Foo BarResponseBody result type (default view)", + "description": "Foo Bar result type (default view)", "example": { "bar": [ { @@ -114,6 +114,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -139,6 +142,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden index d60d92056b..5fadaf92f4 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden @@ -55,7 +55,7 @@ definitions: foo: type: string example: "" - description: Foo BarResponseBody result type (default view) + description: Foo Bar result type (default view) example: bar: - string: "" @@ -107,6 +107,7 @@ definitions: bar: - string: "" - string: "" + - string: "" foo: "" additionalProperties: $ref: '#/definitions/GoaFoobar' @@ -132,6 +133,7 @@ definitions: bar: - string: "" - string: "" + - string: "" foo: "" uint32_map: "": 1 diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file0.golden index e3551637c1..a9eee0c4bb 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file0.golden @@ -7,7 +7,7 @@ "definitions": { "Bar": { "example": { - "string": "" + "string": "item" }, "properties": { "string": { @@ -23,10 +23,7 @@ "example": { "bar": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "foo": "" @@ -35,10 +32,7 @@ "bar": { "example": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "items": { diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file1.golden index 0988ba2ddf..0480b2e804 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file1.golden @@ -44,7 +44,7 @@ definitions: type: string example: "" example: - string: "" + string: item GoaFoobar: title: 'Mediatype identifier: application/vnd.goa.foobar; view=default' type: object @@ -54,14 +54,12 @@ definitions: items: $ref: '#/definitions/Bar' example: - - string: "" - - string: "" + - string: item foo: type: string example: "" description: Test EndpointOKResponseBody result type (default view) example: bar: - - string: "" - - string: "" + - string: item foo: "" diff --git a/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden b/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden index 4b648409e2..c359d133a6 100644 --- a/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden @@ -7,11 +7,11 @@ "definitions": { "Bar": { "example": { - "string": "" + "string": "item" }, "properties": { "string": { - "example": "", + "example": "item", "maxLength": 42, "minLength": 0, "type": "string" @@ -22,15 +22,22 @@ }, "Foobar": { "example": { - "bar": [], + "bar": [ + { + "string": "item" + } + ], "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." + "item" ] }, "properties": { "bar": { - "example": [], + "example": [ + { + "string": "item" + } + ], "items": { "$ref": "#/definitions/Bar" }, @@ -40,11 +47,10 @@ }, "foo": { "example": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." + "item" ], "items": { - "example": "Molestiae dolor eveniet omnis atque.", + "example": "item", "type": "string" }, "maxItems": 42, diff --git a/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden b/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden index b522e657a7..f1da44a905 100644 --- a/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden @@ -42,11 +42,11 @@ definitions: properties: string: type: string - example: "" + example: item minLength: 0 maxLength: 42 example: - string: "" + string: item Foobar: title: Foobar type: object @@ -55,21 +55,21 @@ definitions: type: array items: $ref: '#/definitions/Bar' - example: [] + example: + - string: item minItems: 0 maxItems: 42 foo: type: array items: type: string - example: Molestiae dolor eveniet omnis atque. + example: item example: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - item minItems: 0 maxItems: 42 example: - bar: [] + bar: + - string: item foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - item diff --git a/http/codegen/openapi/v3/builder.go b/http/codegen/openapi/v3/builder.go index d3469ff347..8fba4b222b 100644 --- a/http/codegen/openapi/v3/builder.go +++ b/http/codegen/openapi/v3/builder.go @@ -1,3 +1,5 @@ +// This file builds OpenAPI 3 operations from HTTP endpoints. It uses the +// request or response being described to choose each example value. package openapiv3 import ( @@ -29,20 +31,27 @@ const ( ) // New returns the OpenAPI specification conforming to the given version -// (openapi.Version30 or openapi.Version32) for the given API. It returns nil -// if the design does not define HTTP endpoints. +// (openapi.Version30 or openapi.Version32) for the given API. It returns nil if +// the design does not define HTTP endpoints. func New(root *expr.RootExpr, ver openapi.Version) *OpenAPI { - if root == nil || root.API == nil || root.API.HTTP == nil || len(root.API.HTTP.Services) == 0 { - // No HTTP transport + if root == nil || root.API == nil { return nil } + return NewWithValues( + root, + ver, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) +} - m, ok := root.API.Meta.Last("openapi:example") - if !ok { - m, ok = root.API.Meta.Last("swagger:example") - } - if ok && m == "false" { - root.API.ExampleGenerator.Randomizer = nil +// NewWithValues returns an OpenAPI specification using values in place of +// matching titles, descriptions, and examples from the evaluated design. +// The generator supplies examples for attributes that have no matching value. +func NewWithValues(root *expr.RootExpr, ver openapi.Version, generator *expr.ExampleGenerator, values openapi.Values) *OpenAPI { + if root == nil || root.API == nil || root.API.HTTP == nil || len(root.API.HTTP.Services) == 0 { + // No HTTP transport + return nil } specVersion := OpenAPIVersion @@ -51,14 +60,14 @@ func New(root *expr.RootExpr, ver openapi.Version) *OpenAPI { } var ( - bodies, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, ver) + bodies, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, ver, generator, values) - info = buildInfo(root.API, ver) - comps = buildComponents(root, types) - servers = buildServers(root.API.Servers, ver) - paths = buildPaths(root.API.HTTP, bodies, root.API, ver) + info = buildInfo(root.API, ver, values) + comps = buildComponents(root, types, values) + servers = buildServers(root.API.Servers, ver, values) + paths = buildPaths(root.API.HTTP, bodies, root.API, ver, generator, values) security = buildSecurityRequirements(root.API.Requirements) - tags = buildTags(root.API, ver) + tags = buildTags(root.API, ver, values) ) return &OpenAPI{ @@ -73,14 +82,14 @@ func New(root *expr.RootExpr, ver openapi.Version) *OpenAPI { } // buildInfo builds the OpenAPI Info object. -func buildInfo(api *expr.APIExpr, ver openapi.Version) *Info { - title := api.Title +func buildInfo(api *expr.APIExpr, ver openapi.Version, values openapi.Values) *Info { + title := values.Title(api, api.Title) if title == "" { title = "Goa API" // cannot be empty as per OpenAPI spec } info := &Info{ Title: title, - Description: api.Description, + Description: values.Description(api, api.Description), TermsOfService: api.TermsOfService, Version: api.Version, Extensions: openapi.ExtensionsFromExpr(api.Meta), @@ -107,16 +116,22 @@ func buildInfo(api *expr.APIExpr, ver openapi.Version) *Info { } // buildComponents builds the OpenAPI Components object. -func buildComponents(root *expr.RootExpr, types map[string]*openapi.Schema) *Components { +func buildComponents(root *expr.RootExpr, types map[string]*openapi.Schema, values openapi.Values) *Components { var schemesRef map[string]*SecuritySchemeRef { schemesRef = make(map[string]*SecuritySchemeRef) for _, s := range root.API.HTTP.Services { + if !openapi.MustGenerate(s.Meta) || !openapi.MustGenerate(s.ServiceExpr.Meta) { + continue + } for _, e := range s.HTTPEndpoints { + if !openapi.MustGenerate(e.Meta) || !openapi.MustGenerate(e.MethodExpr.Meta) { + continue + } for _, r := range e.Requirements { for _, sch := range r.Schemes { schemesRef[sch.Hash()] = &SecuritySchemeRef{ - Value: buildSecurityScheme(sch), + Value: buildSecurityScheme(sch, values), } } } @@ -131,7 +146,7 @@ func buildComponents(root *expr.RootExpr, types map[string]*openapi.Schema) *Com // buildPaths builds the OpenAPI Paths map with key as the HTTP path string and // the value as the corresponding PathItem object. -func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, api *expr.APIExpr, ver openapi.Version) map[string]*PathItem { +func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, api *expr.APIExpr, ver openapi.Version, generator *expr.ExampleGenerator, values openapi.Values) map[string]*PathItem { var paths = make(map[string]*PathItem) for _, svc := range h.Services { if !openapi.MustGenerate(svc.Meta) || !openapi.MustGenerate(svc.ServiceExpr.Meta) { @@ -150,7 +165,7 @@ func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, // Remove any wildcards that is defined in path as a workaround to // https://github.com/OAI/OpenAPI-Specification/issues/291 key = expr.HTTPWildcardRegex.ReplaceAllString(key, "/{$1}") - operation := buildOperation(key, r, sbod[e.Name()], api.ExampleGenerator, api.Meta, ver) + operation := buildOperation(key, r, sbod[e.Name()], generator, api.Meta, ver, values) path, ok := paths[key] if !ok { path = new(PathItem) @@ -191,7 +206,7 @@ func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, // Replace wildcards in the path to OpenAPI path parameter form // e.g. "/ui/{*filepath}" -> "/ui/{filepath}" key = expr.HTTPWildcardRegex.ReplaceAllString(key, "/{$1}") - operation := buildFileServerOperation(key, f, api) + operation := buildFileServerOperation(key, f, api, values) path, ok := paths[key] if !ok { path = new(PathItem) @@ -205,7 +220,7 @@ func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, } // buildOperation builds the OpenAPI Operation object for the given path. -func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand *expr.ExampleGenerator, meta expr.MetaExpr, ver openapi.Version) *Operation { +func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand *expr.ExampleGenerator, meta expr.MetaExpr, ver openapi.Version, values openapi.Values) *Operation { e := r.Endpoint m := e.MethodExpr svc := e.Service @@ -254,9 +269,9 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand ct = "multipart/form-data" } mt := &MediaType{Schema: bodies.RequestBody} - initExamples(mt, e.Body, rand.Rebased(bodyExampleID(m.Service.Name, e.Name(), "request"))) + initExamples(mt, e.Body, rand.At(expr.RequestBodyExampleIdentity(e)), values) requestBody = &RequestBodyRef{Value: &RequestBody{ - Description: requestBodyDescription(e), + Description: requestBodyDescription(e, values), Required: e.Body.Type != expr.Empty, Content: map[string]*MediaType{ct: mt}, Extensions: openapi.ExtensionsFromExpr(e.Body.Meta), @@ -266,13 +281,15 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand // parameters var params []*ParameterRef { - ps := paramsFromPath(e, key, rand) - ps = append(ps, paramsFromHeadersAndCookies(e, rand)...) + ps := paramsFromPath(e, key, rand, values) + ps = append(ps, paramsFromHeadersAndCookies(e, rand, values)...) if ver == openapi.Version32 && e.UsesSSE() && e.SSE.RequestIDField != "" { // The generated handler reads the Last-Event-ID header directly so // the header does not appear in the endpoint headers expression. att := expr.AsObject(m.Payload.Type).Attribute(e.SSE.RequestIDField) - ps = append(ps, paramFor(att, "Last-Event-ID", "header", false, rand.Field(m.Payload, e.SSE.RequestIDField))) + owner := expr.MethodPayloadExampleIdentity(m) + identity := exampleFieldIdentity(m.Payload, e.SSE.RequestIDField, owner) + ps = append(ps, paramFor(att, "Last-Event-ID", "header", false, rand, identity, values)) } if e.MapQueryParams != nil { name := *e.MapQueryParams @@ -299,7 +316,8 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand // responses responses := make(map[string]*ResponseRef, len(e.Responses)) - for _, r := range e.Responses { + responseBodyIndexes := make(map[int]int) + for i, r := range e.Responses { var resultCT string switch { case e.UsesWebSocket(): @@ -314,29 +332,43 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand bodies.ResponseBodies[r.StatusCode] = b } case e.UsesSSE(): - resultCT = responseContentType(r) + resultCT = openapi.ResponseContentType(r) r = r.Dup() r.ContentType = "text/event-stream" } - resp := responseFromExpr(r, bodies.ResponseBodies, rand) + var body *openapi.Schema + if r.Body.Type != expr.Empty { + bodyIndex := responseBodyIndexes[r.StatusCode] + body = bodies.ResponseBodies[r.StatusCode][bodyIndex] + responseBodyIndexes[r.StatusCode]++ + } + owner := expr.MethodResultExampleIdentity(m) + bodyOwner := expr.ResponseBodyExampleIdentity(e, e.Responses[i]) + resp := responseFromExpr(r, body, rand, m.Result, owner, bodyOwner, "", values) if ver == openapi.Version32 && e.UsesSSE() { setSSEContent(resp, bodies, resultCT, m.HasMixedResults()) } responses[strconv.Itoa(r.StatusCode)] = &ResponseRef{Value: resp} } for _, er := range e.HTTPErrors { - if er.Description != "" && er.Response.Description == "" { - er.Response.Description = er.Description - } - resp := responseFromExpr(er.Response, bodies.ResponseBodies, rand) + var body *openapi.Schema + if er.Response.Body.Type != expr.Empty { + bodyIndex := responseBodyIndexes[er.Response.StatusCode] + body = bodies.ResponseBodies[er.Response.StatusCode][bodyIndex] + responseBodyIndexes[er.Response.StatusCode]++ + } + owner := expr.MethodErrorExampleIdentity(m, er.ErrorExpr) + bodyOwner := expr.ErrorResponseBodyExampleIdentity(e, er) + errorDescription := values.Description(er.ErrorExpr, er.Description) + resp := responseFromExpr(er.Response, body, rand, er.AttributeExpr, owner, bodyOwner, errorDescription, values) desc := er.Name if resp.Description != nil { desc += ": " + *resp.Description } resp.Description = &desc - if er.Type == expr.ErrorResult && len(er.Response.Body.ExtractUserExamples()) == 0 { + if example, ok := openapi.ErrorResponseExample(er.ErrorExpr, er.Response.Body, rand.At(bodyOwner), values); ok { for _, content := range resp.Content { - content.Example = nil + content.Example = example } } responses[strconv.Itoa(er.Response.StatusCode)] = &ResponseRef{Value: resp} @@ -369,14 +401,14 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand return &Operation{ Tags: tagNames, Summary: summary, - Description: e.Description(), + Description: values.Description(e.MethodExpr, e.Description()), OperationID: parseOperationIDTemplate(operationIDFormat, svc.Name(), e.Name(), routeIndex), Parameters: params, RequestBody: requestBody, Responses: responses, Security: security, Deprecated: deprecated, - ExternalDocs: openapi.DocsFromExpr(m.Docs, m.Meta), + ExternalDocs: openapi.DocsFromExprWithValues(m.Docs, m.Meta, values), Extensions: openapi.ExtensionsFromMethod(m), } } @@ -385,17 +417,19 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand // HTTP body, payload, or referenced type. It uses a deterministic default for // computed bodies so generated OpenAPI requestBody objects are always // self-describing. -func requestBodyDescription(e *expr.HTTPEndpointExpr) string { - if e.Body.Description != "" { - return e.Body.Description +func requestBodyDescription(e *expr.HTTPEndpointExpr, values openapi.Values) string { + if description := values.Description(e.Body.AuthoredAttribute(), e.Body.Description); description != "" { + return description } if ut, ok := e.Body.Type.(expr.UserType); ok { - if desc := ut.Attribute().Description; desc != "" { + if desc := values.Description(ut.Attribute().AuthoredAttribute(), ut.Attribute().Description); desc != "" { return desc } } - if e.MethodExpr.Payload != nil && e.MethodExpr.Payload.Description != "" { - return e.MethodExpr.Payload.Description + if e.MethodExpr.Payload != nil { + if desc := values.Description(e.MethodExpr.Payload.AuthoredAttribute(), e.MethodExpr.Payload.Description); desc != "" { + return desc + } } return defaultRequestBodyDescription(e) } @@ -408,7 +442,7 @@ func defaultRequestBodyDescription(e *expr.HTTPEndpointExpr) string { } // buildFileServerOperation builds the OpenAPI Operation object for the given file server. -func buildFileServerOperation(key string, fs *expr.HTTPFileServerExpr, api *expr.APIExpr) *Operation { +func buildFileServerOperation(key string, fs *expr.HTTPFileServerExpr, api *expr.APIExpr, values openapi.Values) *Operation { wildcards := expr.ExtractHTTPWildcards(key) svc := fs.Service @@ -489,14 +523,14 @@ func buildFileServerOperation(key string, fs *expr.HTTPFileServerExpr, api *expr return &Operation{ OperationID: parseOperationIDTemplate(operationIDFormat, svc.Name(), key, 0), - Description: fs.Description, + Description: values.Description(fs, fs.Description), Summary: summary, Parameters: params, Responses: responses, Tags: tagNames, Security: buildSecurityRequirements(api.Requirements), Deprecated: false, - ExternalDocs: openapi.DocsFromExpr(fs.Docs, fs.Meta), + ExternalDocs: openapi.DocsFromExprWithValues(fs.Docs, fs.Meta, values), Extensions: openapi.ExtensionsFromExpr(fs.Meta), } } @@ -530,7 +564,7 @@ func parseOperationIDTemplate(template, service, method string, routeIndex int) // buildServers builds the OpenAPI Server objects from the given server // expressions. -func buildServers(servers []*expr.ServerExpr, ver openapi.Version) []*Server { +func buildServers(servers []*expr.ServerExpr, ver openapi.Version, values openapi.Values) []*Server { var svrs []*Server for _, svr := range servers { if !openapi.MustGenerate(svr.Meta) { @@ -542,11 +576,7 @@ func buildServers(servers []*expr.ServerExpr, ver openapi.Version) []*Server { continue } - var ( - serverVariable = make(map[string]*ServerVariable) - defaultValue any - validationValues []any - ) + serverVariable := make(map[string]*ServerVariable) // Get the first URL expression in the host by default. // Host expression must have at least one URI (validations would have failed @@ -564,10 +594,11 @@ func buildServers(servers []*expr.ServerExpr, ver openapi.Version) []*Server { // retrieve host variables vars := expr.AsObject(host.Variables.Type) for _, v := range *vars { - defaultValue = v.Attribute.DefaultValue + defaultValue := v.Attribute.DefaultValue + var validationValues []any if v.Attribute.Validation != nil && len(v.Attribute.Validation.Values) > 0 { - validationValues = append(validationValues, v.Attribute.Validation.Values...) + validationValues = append([]any(nil), v.Attribute.Validation.Values...) if defaultValue == nil { defaultValue = v.Attribute.Validation.Values[0] } @@ -577,14 +608,14 @@ func buildServers(servers []*expr.ServerExpr, ver openapi.Version) []*Server { serverVariable[v.Name] = &ServerVariable{ Enum: validationValues, Default: defaultValue, - Description: host.Variables.Description, + Description: values.Description(v.Attribute.AuthoredAttribute(), v.Attribute.Description), } } } server = &Server{ URL: string(uExpr), - Description: svr.Description, + Description: values.Description(svr, svr.Description), Variables: serverVariable, } if ver == openapi.Version32 { @@ -632,20 +663,21 @@ func buildSecurityRequirements(reqs []*expr.SecurityExpr) SecurityRequirements { // buildSecurityScheme builds the OpenAPI SecurityScheme object from the // top-level security scheme definition. -func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme { +func buildSecurityScheme(se *expr.SchemeExpr, values openapi.Values) *SecurityScheme { + description := values.Description(se.AuthoredScheme(), se.Description) var scheme *SecurityScheme switch se.Kind { case expr.BasicAuthKind: scheme = &SecurityScheme{ Type: "http", Scheme: "basic", - Description: se.Description, + Description: description, Extensions: openapi.ExtensionsFromExpr(se.Meta), } case expr.APIKeyKind: scheme = &SecurityScheme{ Type: "apiKey", - Description: se.Description, + Description: description, In: se.In, Name: se.Name, Extensions: openapi.ExtensionsFromExpr(se.Meta), @@ -659,7 +691,7 @@ func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme { Type: "http", Scheme: "bearer", BearerFormat: bearerFormat, - Description: se.Description, + Description: description, Extensions: openapi.ExtensionsFromExpr(se.Meta), } case expr.OAuth2Kind: @@ -699,7 +731,7 @@ func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme { } scheme = &SecurityScheme{ Type: "oauth2", - Description: se.Description, + Description: description, Flows: &flows, Extensions: openapi.ExtensionsFromExpr(se.Meta), } @@ -708,7 +740,7 @@ func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme { } // buildTags builds the OpenAPI Tag object from the API expression. -func buildTags(api *expr.APIExpr, ver openapi.Version) []*openapi.Tag { +func buildTags(api *expr.APIExpr, ver openapi.Version, values openapi.Values) []*openapi.Tag { m := make(map[string]*openapi.Tag) for _, t := range openapi.TagsFromExpr(api.Meta, ver) { m[t.Name] = t @@ -743,7 +775,7 @@ func buildTags(api *expr.APIExpr, ver openapi.Version) []*openapi.Tag { } tags = append(tags, &openapi.Tag{ Name: s.Name(), - Description: s.Description(), + Description: values.Description(s.ServiceExpr, s.Description()), }) } } diff --git a/http/codegen/openapi/v3/builder_test.go b/http/codegen/openapi/v3/builder_test.go index 9477a1fd23..3d8499df18 100644 --- a/http/codegen/openapi/v3/builder_test.go +++ b/http/codegen/openapi/v3/builder_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI v3 operation construction, body schemas, and +// examples produced from evaluated HTTP endpoint designs. package openapiv3 import ( @@ -67,7 +69,7 @@ func TestBuildInfo(t *testing.T) { License: &expr.LicenseExpr{Name: licenseName, URL: licenseURL}, } - info := buildInfo(api, openapi.Version30) + info := buildInfo(api, openapi.Version30, openapi.Values{}) expected := c.Title if api.Title == "" { @@ -92,6 +94,46 @@ func TestBuildInfo(t *testing.T) { } } +func TestNewWithValues(t *testing.T) { + root := codegen.RunDSL(t, localizedValuesDSL) + service := root.Service("messages") + method := service.Method("show") + values := (openapi.Values{}). + WithTitle(root.API, "Localized API"). + WithDescription(root.API, "Localized API description"). + WithDescription(service, "Localized service description"). + WithDescription(method, "Localized method description") + + spec := NewWithValues( + root, + openapi.Version30, + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + require.Equal(t, "Localized API", spec.Info.Title) + require.Equal(t, "Localized API description", spec.Info.Description) + require.Equal(t, "Localized method description", spec.Paths["/messages"].Get.Description) + require.Contains(t, spec.Paths["/messages"].Get.Tags, "messages") + require.Equal(t, "Original API", root.API.Title) + require.Equal(t, "Original method description", method.Description) +} + +var localizedValuesDSL = func() { + dsl.API("messages", func() { + dsl.Title("Original API") + dsl.Description("Original API description") + }) + dsl.Service("messages", func() { + dsl.Description("Original service description") + dsl.Method("show", func() { + dsl.Description("Original method description") + dsl.HTTP(func() { + dsl.GET("/messages") + }) + }) + }) +} + func TestNoSecurityOverridesAPISecurity(t *testing.T) { root := codegen.RunDSL(t, noSecurityOverridesAPISecurityDSL) spec := New(root, openapi.Version30) @@ -181,6 +223,62 @@ func TestStreamingResponseStatusCodes(t *testing.T) { require.NotContains(t, websocketResponses, "200") } +// TestSSEItemSchemaMatchesDataContract verifies OpenAPI describes the same +// presence and encoding used by generated SSE clients and servers. +func TestSSEItemSchemaMatchesDataContract(t *testing.T) { + root := codegen.RunDSL(t, func() { + count := dsl.Type("EventCount", dsl.Int) + dsl.Service("Events", func() { + dsl.Method("Optional", func() { + dsl.StreamingResult(func() { + dsl.Attribute("value", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/optional") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("RequiredAlias", func() { + dsl.StreamingResult(func() { + dsl.Attribute("value", count) + dsl.Required("value") + }) + dsl.HTTP(func() { + dsl.GET("/required-alias") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("Structured", func() { + dsl.StreamingResult(func() { + dsl.Attribute("value", func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Required("value") + }) + dsl.HTTP(func() { + dsl.GET("/structured") + dsl.ServerSentEvents("value") + }) + }) + }) + }) + spec := New(root, openapi.Version32) + + optional := spec.Paths["/optional"].Get.Responses["200"].Value.Content["text/event-stream"].ItemSchema + require.NotContains(t, optional.Required, "data") + require.Equal(t, openapi.Type(openapi.String), optional.Properties["data"].Type) + require.Empty(t, optional.Properties["data"].ContentMediaType) + + requiredAlias := spec.Paths["/required-alias"].Get.Responses["200"].Value.Content["text/event-stream"].ItemSchema + require.Contains(t, requiredAlias.Required, "data") + require.Empty(t, requiredAlias.Properties["data"].ContentMediaType) + + structured := spec.Paths["/structured"].Get.Responses["200"].Value.Content["text/event-stream"].ItemSchema + require.Contains(t, structured.Required, "data") + require.Equal(t, "application/json", structured.Properties["data"].ContentMediaType) + require.NotNil(t, structured.Properties["data"].ContentSchema) +} + func TestOperationSecurityMarshal(t *testing.T) { securityCases := map[string]struct { operation Operation @@ -227,6 +325,34 @@ func TestOperationSecurityMarshal(t *testing.T) { } } +func TestSecuritySchemesIncludeVisibleOperationsOnly(t *testing.T) { + root := codegen.RunDSL(t, visibleSecuritySchemesDSL) + spec := New(root, openapi.Version30) + + visible := root.Service("visible").Method("read").Requirements[0].Schemes[0].Hash() + hiddenMethod := root.Service("mixed").Method("hidden").Requirements[0].Schemes[0].Hash() + hiddenService := root.Service("hidden").Method("read").Requirements[0].Schemes[0].Hash() + require.Contains(t, spec.Components.SecuritySchemes, visible) + require.NotContains(t, spec.Components.SecuritySchemes, hiddenMethod) + require.NotContains(t, spec.Components.SecuritySchemes, hiddenService) +} + +func TestBuildServersKeepsVariableValuesSeparate(t *testing.T) { + root := codegen.RunDSL(t, serverVariablesDSL) + servers := buildServers(root.API.Servers, openapi.Version30, openapi.Values{}) + require.Len(t, servers, 1) + + region := servers[0].Variables["region"] + require.Equal(t, []any{"west", "east"}, region.Enum) + require.Equal(t, "west", region.Default) + require.Equal(t, "Deployment region", region.Description) + + stage := servers[0].Variables["stage"] + require.Equal(t, []any{"test", "production"}, stage.Enum) + require.Equal(t, "production", stage.Default) + require.Equal(t, "Deployment stage", stage.Description) +} + type param struct { Name string In string @@ -236,6 +362,75 @@ type param struct { Type typ } +var visibleSecuritySchemesDSL = func() { + var ( + VisibleAuth = dsl.JWTSecurity("visible_auth") + HiddenMethodAuth = dsl.JWTSecurity("hidden_method_auth") + HiddenServiceAuth = dsl.JWTSecurity("hidden_service_auth") + ) + + dsl.Service("visible", func() { + dsl.Method("read", func() { + dsl.Security(VisibleAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/visible") + }) + }) + }) + dsl.Service("mixed", func() { + dsl.Method("hidden", func() { + dsl.Meta("openapi:generate", "false") + dsl.Security(HiddenMethodAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/hidden-method") + }) + }) + }) + dsl.Service("hidden", func() { + dsl.Meta("openapi:generate", "false") + dsl.Method("read", func() { + dsl.Security(HiddenServiceAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/hidden-service") + }) + }) + }) +} + +var serverVariablesDSL = func() { + dsl.API("server variables", func() { + dsl.Server("public", func() { + dsl.Host("production", func() { + dsl.URI("https://{region}.{stage}.example.com") + dsl.Variable("region", dsl.String, "Deployment region", func() { + dsl.Default("west") + dsl.Enum("west", "east") + }) + dsl.Variable("stage", dsl.String, "Deployment stage", func() { + dsl.Default("production") + dsl.Enum("test", "production") + }) + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) +} + type requestBody struct { Description string Type typ @@ -334,7 +529,7 @@ func TestBuildOperation(t *testing.T) { var types map[string]*openapi.Schema { var bds map[string]map[string]*EndpointBodies - bds, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30) + bds, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) if svc, ok := bds[svcName]; ok { bodies, ok = svc[c.Name] if !ok { @@ -366,7 +561,8 @@ func TestBuildOperation(t *testing.T) { return } - op := buildOperation(c.Name, route, bodies, expr.NewRandom(c.Name), root.API.Meta, openapi.Version30) + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(c.Name)) + op := buildOperation(c.Name, route, bodies, generator, root.API.Meta, openapi.Version30, openapi.Values{}) if op.Description != c.ExpectedDescription { t.Errorf("got description %q for method %q, expected %q", op.Description, c.Name, c.ExpectedDescription) @@ -455,7 +651,8 @@ func TestBuildOperationID(t *testing.T) { if s.Name() == svcName { for _, e := range s.HTTPEndpoints { for i, r := range e.Routes { - op := buildOperation(c.Name, r, &EndpointBodies{}, expr.NewRandom(c.Name), api.Meta, openapi.Version30) + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(c.Name)) + op := buildOperation(c.Name, r, &EndpointBodies{}, generator, api.Meta, openapi.Version30, openapi.Values{}) if len(c.ExpectedOperationIDs) == 0 { t.Error("no expected operation IDs") diff --git a/http/codegen/openapi/v3/description_ownership_test.go b/http/codegen/openapi/v3/description_ownership_test.go new file mode 100644 index 0000000000..38e379b709 --- /dev/null +++ b/http/codegen/openapi/v3/description_ownership_test.go @@ -0,0 +1,98 @@ +// This file verifies that shared OpenAPI v3 components use the named Goa type +// description while each response keeps its own error description. +package openapiv3 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestSharedErrorComponentDescription(t *testing.T) { + versions := []struct { + name string + version openapi.Version + }{ + {"3.0", openapi.Version30}, + {"3.2", openapi.Version32}, + } + designs := []struct { + name string + dsl func() + description string + }{ + {"method order", testdata.SharedErrorDescriptionDSL, "Shared error value"}, + {"reversed method order", testdata.ReversedSharedErrorDescriptionDSL, "Shared error value"}, + {"undescribed type", testdata.UndescribedSharedErrorDSL, ""}, + } + for _, version := range versions { + t.Run(version.name, func(t *testing.T) { + for _, design := range designs { + t.Run(design.name, func(t *testing.T) { + root := codegen.RunDSL(t, design.dsl) + spec := New( + root, + version.version, + ) + require.Equal(t, design.description, spec.Components.Schemas["SharedError"].Description) + }) + } + }) + } +} + +func TestSharedErrorResponseDescriptions(t *testing.T) { + for _, version := range []openapi.Version{openapi.Version30, openapi.Version32} { + root := codegen.RunDSL(t, testdata.SharedErrorDescriptionDSL) + spec := New(root, version) + + first := spec.Paths["/first"].Get.Responses["400"].Value.Description + second := spec.Paths["/second"].Get.Responses["400"].Value.Description + require.NotNil(t, first) + require.NotNil(t, second) + require.Equal(t, "first_error: First failure", *first) + require.Equal(t, "second_error: Second failure", *second) + } +} + +func TestSharedErrorComponentLocalizedDescription(t *testing.T) { + for _, version := range []openapi.Version{openapi.Version30, openapi.Version32} { + root := codegen.RunDSL(t, testdata.SharedErrorDescriptionDSL) + sharedError := root.UserType("SharedError") + values := (openapi.Values{}).WithDescription(sharedError.Attribute(), "Localized shared error") + spec := NewWithValues( + root, + version, + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + + require.Equal(t, "Localized shared error", spec.Components.Schemas["SharedError"].Description) + } +} + +func TestUndescribedSharedErrorIgnoresLocalizedResponseDescription(t *testing.T) { + for _, version := range []openapi.Version{openapi.Version30, openapi.Version32} { + root := codegen.RunDSL(t, testdata.UndescribedSharedErrorDSL) + firstError := root.Service("errors").Method("first").Error("first_error") + values := (openapi.Values{}). + WithDescription(firstError, "Localized first failure"). + WithDescription(firstError.AttributeExpr, "Localized first failure") + spec := NewWithValues( + root, + version, + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + + require.Empty(t, spec.Components.Schemas["SharedError"].Description) + response := spec.Paths["/first"].Get.Responses["400"].Value.Description + require.NotNil(t, response) + require.Equal(t, "first_error: Localized first failure", *response) + } +} diff --git a/http/codegen/openapi/v3/example.go b/http/codegen/openapi/v3/example.go index 6c1e0291c7..a7eae4c7d7 100644 --- a/http/codegen/openapi/v3/example.go +++ b/http/codegen/openapi/v3/example.go @@ -1,3 +1,4 @@ +// This file adds authored or generated examples to OpenAPI 3 values. package openapiv3 import ( @@ -15,8 +16,13 @@ type ( ) // initExample sets the example or examples of the given object. -func initExamples(obj exampler, attr *expr.AttributeExpr, r *expr.ExampleGenerator) { - examples := attr.ExtractUserExamples() +func initExamples(obj exampler, attr *expr.AttributeExpr, r *expr.ExampleGenerator, values openapi.Values) { + selected := values.Example(attr, r) + if selected == nil { + obj.setExample(nil) + return + } + examples := values.Examples(attr, attr.ExtractUserExamples()) switch { case len(examples) > 1: refs := make(map[string]*ExampleRef, len(examples)) @@ -33,6 +39,6 @@ func initExamples(obj exampler, attr *expr.AttributeExpr, r *expr.ExampleGenerat case len(examples) > 0: obj.setExample(openapi.ProjectExample(attr, examples[0].Value)) default: - obj.setExample(openapi.Example(attr, r)) + obj.setExample(openapi.ProjectExample(attr, selected)) } } diff --git a/http/codegen/openapi/v3/example_test.go b/http/codegen/openapi/v3/example_test.go new file mode 100644 index 0000000000..8c90ee434a --- /dev/null +++ b/http/codegen/openapi/v3/example_test.go @@ -0,0 +1,33 @@ +// This file checks how OpenAPI 3 objects receive authored and replacement +// examples without changing the evaluated attribute. +package openapiv3 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" +) + +func TestInitExamplesUsesReplacementDescription(t *testing.T) { + first := &expr.ExampleExpr{Summary: "first", Description: "original", Value: "one"} + second := &expr.ExampleExpr{Summary: "second", Description: "unchanged", Value: "two"} + attribute := &expr.AttributeExpr{ + Type: expr.String, + UserExamples: []*expr.ExampleExpr{first, second}, + } + values := (openapi.Values{}).WithDescription(first, "translated") + media := new(MediaType) + userType := &expr.UserTypeExpr{AttributeExpr: attribute, TypeName: "Message"} + generator := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.UserTypeExampleIdentity(userType), + ) + + initExamples(media, attribute, generator, values) + + require.Equal(t, "translated", media.Examples["first"].Value.Description) + require.Equal(t, "unchanged", media.Examples["second"].Value.Description) + require.Equal(t, "original", first.Description) +} diff --git a/http/codegen/openapi/v3/files.go b/http/codegen/openapi/v3/files.go index 29cd8a0a83..80f63037e0 100644 --- a/http/codegen/openapi/v3/files.go +++ b/http/codegen/openapi/v3/files.go @@ -1,3 +1,5 @@ +// This file builds OpenAPI 3 JSON and YAML files from one HTTP design. Each +// example comes from the request or response described in the file. package openapiv3 import ( @@ -11,5 +13,18 @@ import ( // path is the output path of the files relative to the gen directory, without // extension. func Files(root *expr.RootExpr, ver openapi.Version, path string) []*codegen.File { - return openapi.Files(New(root, ver), root.API.Meta, "openapi_v3", path) + return FilesWithValues( + root, + ver, + path, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) +} + +// FilesWithValues returns OpenAPI files using values in place of matching +// titles, descriptions, and examples from the evaluated design. The generator +// supplies examples for attributes that have no matching value. +func FilesWithValues(root *expr.RootExpr, ver openapi.Version, path string, generator *expr.ExampleGenerator, values openapi.Values) []*codegen.File { + return openapi.Files(NewWithValues(root, ver, generator, values), root.API.Meta, "openapi_v3", path) } diff --git a/http/codegen/openapi/v3/files_test.go b/http/codegen/openapi/v3/files_test.go index 992a3a8618..a8f4dfc6f2 100644 --- a/http/codegen/openapi/v3/files_test.go +++ b/http/codegen/openapi/v3/files_test.go @@ -1,3 +1,5 @@ +// This file renders complete OpenAPI 3.0 and 3.2 documents from prepared HTTP +// designs and compares output produced with run-owned example state. package openapiv3_test import ( @@ -32,9 +34,11 @@ func TestFiles(t *testing.T) { {"file-service-swagger", testdata.FileServiceSwaggerDSL}, {"file-service-wildcard", testdata.FileServiceWildcardDSL}, {"valid", testdata.SimpleDSL}, + {"bytes-example", testdata.BytesExampleDSL}, {"multiple-services", testdata.MultipleServicesDSL}, {"multiple-views", testdata.MultipleViewsDSL}, {"explicit-view", testdata.ExplicitViewDSL}, + {"released-response-collection-names", testdata.ReleasedResponseCollectionNamesDSL}, {"security", testdata.SecurityDSL}, {"bearer-security", testdata.BearerSecurityDSL}, {"server-host-with-variables", testdata.ServerHostWithVariablesDSL}, @@ -67,6 +71,7 @@ func TestFiles(t *testing.T) { {"array", testdata.ArrayValidationDSL}, // Error examples {"error-examples", testdata.ErrorExamplesDSL}, + {"shared-error-description", testdata.SharedErrorDescriptionDSL}, // Streaming endpoints: OpenAPI 3.2 constructs must not leak into // 3.0 documents. {"sse-string", testdata.SSEStringDSL}, @@ -78,8 +83,6 @@ func TestFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) oFiles := openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30) for i, o := range oFiles { @@ -123,6 +126,7 @@ func TestFilesV32(t *testing.T) { DSL func() }{ {"valid", testdata.SimpleDSL}, + {"bytes-example", testdata.BytesExampleDSL}, {"v3.2-meta", testdata.OpenAPIV32MetaDSL}, {"with-tags", testdata.WithTagsDSL}, {"server-host-with-variables", testdata.ServerHostWithVariablesDSL}, @@ -134,11 +138,10 @@ func TestFilesV32(t *testing.T) { {"sse-mixed-results", testdata.MixedResultsDSL}, {"websocket", testdata.StreamingResultDSL}, {"alias-type", testdata.AliasTypeDSL}, + {"shared-error-description", testdata.SharedErrorDescriptionDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) oFiles := openapiv3.Files(root, openapi.Version32, openapi.DefaultPath32) wantPaths := []string{ @@ -183,6 +186,68 @@ func TestFilesV32(t *testing.T) { } } +// TestAuthoredExampleFixturesIgnoreRandomizer verifies that golden designs +// which do not test generated examples describe every displayed value. +func TestAuthoredExampleFixturesIgnoreRandomizer(t *testing.T) { + cases := []struct { + Name string + DSL func() + Version openapi.Version + }{ + {"alias-type", testdata.AliasTypeDSL, openapi.Version30}, + {"array", testdata.ArrayValidationDSL, openapi.Version30}, + {"headers", testdata.HeadersDSL, openapi.Version30}, + {"not-generate-host", testdata.NotGenerateHostDSL, openapi.Version30}, + {"not-generate-server", testdata.NotGenerateServerDSL, openapi.Version30}, + {"path-with-wildcards", testdata.PathWithWildcardDSL, openapi.Version30}, + {"path-with-multiple-wildcards", testdata.PathWithMultipleWildcardDSL, openapi.Version30}, + {"path-with-multiple-explicit-wildcards", testdata.PathWithMultipleExplicitWildcardDSL, openapi.Version30}, + {"sse-all-fields", testdata.SSEAllFieldsDSL, openapi.Version32}, + {"sse-data-field", testdata.SSEDataFieldDSL, openapi.Version32}, + {"sse-mixed-results", testdata.MixedResultsDSL, openapi.Version32}, + {"sse-object", testdata.SSEObjectDSL, openapi.Version32}, + {"sse-request-id", testdata.SSERequestIDDSL, openapi.Version32}, + {"sse-string", testdata.SSEStringDSL, openapi.Version32}, + {"type-extension", testdata.TypeExtensionDSL, openapi.Version30}, + {"websocket", testdata.StreamingResultDSL, openapi.Version32}, + {"with-any", testdata.WithAnyDSL, openapi.Version30}, + {"with-map", testdata.WithMapDSL, openapi.Version30}, + {"with-spaces", testdata.WithSpacesDSL, openapi.Version30}, + {"with-tags", testdata.WithTagsDSL, openapi.Version32}, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + firstRoot := expr.RunDSL(t, c.DSL) + first := openapiv3.NewWithValues( + firstRoot, + c.Version, + expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("first")), + openapi.Values{}, + ) + firstJSON, err := json.Marshal(first) + if err != nil { + t.Fatalf("failed to encode first document: %s", err) + } + + secondRoot := expr.RunDSL(t, c.DSL) + second := openapiv3.NewWithValues( + secondRoot, + c.Version, + expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("second")), + openapi.Values{}, + ) + secondJSON, err := json.Marshal(second) + if err != nil { + t.Fatalf("failed to encode second document: %s", err) + } + + if !bytes.Equal(firstJSON, secondJSON) { + t.Error("OpenAPI examples changed with the randomizer") + } + }) + } +} + func validateSwagger(t *testing.T, b []byte) { swagger, err := openapi3.NewLoader().LoadFromData(b) if err == nil { diff --git a/http/codegen/openapi/v3/parameters.go b/http/codegen/openapi/v3/parameters.go index 1c4c64bc31..cfc898e60a 100644 --- a/http/codegen/openapi/v3/parameters.go +++ b/http/codegen/openapi/v3/parameters.go @@ -1,3 +1,6 @@ +// This file converts HTTP parameters, headers, and cookies into OpenAPI v3 +// values. Each schema and its displayed example use the same new repeatable +// example key. package openapiv3 import ( @@ -11,11 +14,12 @@ import ( // paramsFromPath computes the OpenAPI spec parameters for the given endpoint // HTTP path and query parameters. -func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.ExampleGenerator) []*Parameter { +func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.ExampleGenerator, values openapi.Values) []*Parameter { var ( res []*Parameter params = endpoint.Params wildcards = expr.ExtractHTTPWildcards(path) + owner = expr.MethodPayloadExampleIdentity(endpoint.MethodExpr) ) codegen.WalkMappedAttr(params, func(n, pn string, required bool, at *expr.AttributeExpr) error { // nolint: errcheck in := "query" @@ -26,7 +30,8 @@ func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.Exa if in != "path" && openapiinternal.IsSecurityParameter(endpoint, in, pn) { return nil } - res = append(res, paramFor(at, pn, in, required, rand.Field(endpoint.MethodExpr.Payload, n))) + identity := exampleFieldIdentity(endpoint.MethodExpr.Payload, n, owner) + res = append(res, paramFor(at, pn, in, required, rand, identity, values)) return nil }) return res @@ -34,15 +39,17 @@ func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.Exa // paramsFromHeadersAndCookies computes the OpenAPI spec parameters for the // given endpoint HTTP headers and cookies. -func paramsFromHeadersAndCookies(endpoint *expr.HTTPEndpointExpr, rand *expr.ExampleGenerator) []*Parameter { +func paramsFromHeadersAndCookies(endpoint *expr.HTTPEndpointExpr, rand *expr.ExampleGenerator, values openapi.Values) []*Parameter { var params []*Parameter + owner := expr.MethodPayloadExampleIdentity(endpoint.MethodExpr) expr.WalkMappedAttr(endpoint.Headers, func(name, elem string, att *expr.AttributeExpr) error { // nolint: errcheck if openapiinternal.IsSecurityParameter(endpoint, "header", elem) { return nil } required := endpoint.Headers.IsRequiredNoDefault(name) - params = append(params, paramFor(att, elem, "header", required, rand.Field(endpoint.MethodExpr.Payload, name))) + identity := exampleFieldIdentity(endpoint.MethodExpr.Payload, name, owner) + params = append(params, paramFor(att, elem, "header", required, rand, identity, values)) return nil }) expr.WalkMappedAttr(endpoint.Cookies, func(name, elem string, att *expr.AttributeExpr) error { // nolint: errcheck @@ -50,24 +57,35 @@ func paramsFromHeadersAndCookies(endpoint *expr.HTTPEndpointExpr, rand *expr.Exa return nil } required := endpoint.Cookies.IsRequiredNoDefault(name) - params = append(params, paramFor(att, elem, "cookie", required, rand.Field(endpoint.MethodExpr.Payload, name))) + identity := exampleFieldIdentity(endpoint.MethodExpr.Payload, name, owner) + params = append(params, paramFor(att, elem, "cookie", required, rand, identity, values)) return nil }) return params } +// exampleFieldIdentity returns the repeatable example key for a named field. +// Named user types use a key derived from their type; anonymous objects append +// the field name to the key supplied for their parent. +func exampleFieldIdentity(parent *expr.AttributeExpr, name string, owner expr.ExampleIdentity) expr.ExampleIdentity { + if typ, ok := parent.Type.(expr.UserType); ok { + owner = expr.UserTypeExampleIdentity(typ) + } + return owner.Member(name) +} + // paramFor converts the given attribute into a OpenAPI spec parameter. -func paramFor(att *expr.AttributeExpr, name, in string, required bool, rand *expr.ExampleGenerator) *Parameter { +func paramFor(att *expr.AttributeExpr, name, in string, required bool, rand *expr.ExampleGenerator, identity expr.ExampleIdentity, values openapi.Values) *Parameter { param := &Parameter{ Name: name, In: in, - Description: att.Description, + Description: values.Description(att.AuthoredAttribute(), att.Description), AllowEmptyValue: in == "query", Required: required, - Schema: newSchemafier(rand).schemafy(att), + Schema: newSchemafier(rand.At(identity), values).schemafy(att), Extensions: openapi.ExtensionsFromExpr(att.Meta), } - initExamples(param, att, rand) + initExamples(param, att, rand.At(identity), values) return param } diff --git a/http/codegen/openapi/v3/parameters_test.go b/http/codegen/openapi/v3/parameters_test.go index 4b83cc9b51..52b5d2aa5c 100644 --- a/http/codegen/openapi/v3/parameters_test.go +++ b/http/codegen/openapi/v3/parameters_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI parameters and headers render one stable example +// in both their schema and their displayed example fields. package openapiv3 import ( @@ -6,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" ) func TestParamForAllowEmptyValue(t *testing.T) { @@ -22,15 +25,43 @@ func TestParamForAllowEmptyValue(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + method := &expr.MethodExpr{Name: "parameter", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(test.name)) + require.NotEmpty(t, generator.At(expr.MethodResultExampleIdentity(method)).String()) + identity := expr.MethodPayloadExampleIdentity(method).Member("value") param := paramFor( &expr.AttributeExpr{Type: expr.String}, "value", test.location, false, - expr.NewRandom(test.name), + generator, + identity, + openapi.Values{}, ) require.Equal(t, test.want, param.AllowEmptyValue) + require.Equal(t, param.Schema.Example, param.Example) }) } } + +func TestHeaderSchemaAndDisplayedExampleShareIdentity(t *testing.T) { + field := &expr.AttributeExpr{Type: expr.String} + parent := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "request-id", Attribute: field}, + }} + headers := expr.NewMappedAttributeExpr(parent) + method := &expr.MethodExpr{Name: "headers", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("headers")) + require.NotEmpty(t, generator.At(expr.MethodPayloadExampleIdentity(method)).String()) + + actual := headersFromAttr( + headers, + parent, + expr.MethodResultExampleIdentity(method), + generator, + openapi.Values{}, + )["request-id"].Value + + require.Equal(t, actual.Schema.Example, actual.Example) +} diff --git a/http/codegen/openapi/v3/public_api_test.go b/http/codegen/openapi/v3/public_api_test.go new file mode 100644 index 0000000000..110e52286c --- /dev/null +++ b/http/codegen/openapi/v3/public_api_test.go @@ -0,0 +1,95 @@ +// This file protects the released OpenAPI v3 function signatures and checks +// that their default files match files produced with the root's example +// generator and no replacement values. +package openapiv3_test + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + openapiv3 "goa.design/goa/v3/http/codegen/openapi/v3" +) + +var ( + _ func(*expr.RootExpr, openapi.Version) *openapiv3.OpenAPI = openapiv3.New + _ func(*expr.RootExpr, openapi.Version, string) []*codegen.File = openapiv3.Files + + facadeDSL = func() { + dsl.API("facade", func() { + dsl.Server("facade", func() { + dsl.Host("localhost", func() { + dsl.URI("https://goa.design") + }) + }) + }) + dsl.Service("facade", func() { + dsl.Method("show", func() { + dsl.Payload(func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Result(func() { + dsl.Attribute("answer", dsl.String) + }) + dsl.HTTP(func() { + dsl.POST("/items") + }) + }) + }) + } +) + +func TestDefaultFacadeMatchesWithValues(t *testing.T) { + root := expr.RunDSL(t, facadeDSL) + root.API.RandomizerFactory = expr.NewFakerRandomizerFactory("released facade") + + gotSpec := openapiv3.New(root, openapi.Version30) + wantSpec := openapiv3.NewWithValues( + root, + openapi.Version30, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) + require.Equal(t, wantSpec, gotSpec) + otherSpec := openapiv3.NewWithValues( + root, + openapi.Version30, + expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("other")), + openapi.Values{}, + ) + require.NotEqual(t, otherSpec, gotSpec) + + gotFiles := openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30) + wantFiles := openapiv3.FilesWithValues( + root, + openapi.Version30, + openapi.DefaultPath30, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) + require.Equal(t, renderFiles(t, wantFiles), renderFiles(t, gotFiles)) +} + +// renderFiles runs each file template so the test compares the documents that +// users receive instead of comparing template implementation details. +func renderFiles(t *testing.T, files []*codegen.File) map[string]string { + t.Helper() + + rendered := make(map[string]string, len(files)) + for _, file := range files { + var buf bytes.Buffer + for _, section := range file.SectionTemplates { + tmpl, err := template.New("openapi").Funcs(section.FuncMap).Parse(section.Source) + require.NoError(t, err) + require.NoError(t, tmpl.Execute(&buf, section.Data)) + } + rendered[file.Path] = buf.String() + } + return rendered +} diff --git a/http/codegen/openapi/v3/response.go b/http/codegen/openapi/v3/response.go index cb9de68ebb..9342dff650 100644 --- a/http/codegen/openapi/v3/response.go +++ b/http/codegen/openapi/v3/response.go @@ -1,16 +1,17 @@ +// This file converts HTTP response headers and cookies into OpenAPI v3 values +// without sharing consumed example streams between schema and display fields. package openapiv3 import ( "fmt" "net/http" - "strconv" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/openapi" ) -func headersFromAttr(attr *expr.MappedAttributeExpr, rand *expr.ExampleGenerator) map[string]*HeaderRef { +func headersFromAttr(attr *expr.MappedAttributeExpr, parent *expr.AttributeExpr, owner expr.ExampleIdentity, rand *expr.ExampleGenerator, values openapi.Values) map[string]*HeaderRef { o := expr.AsObject(attr.Type) if len(*o) == 0 { return nil @@ -19,25 +20,24 @@ func headersFromAttr(attr *expr.MappedAttributeExpr, rand *expr.ExampleGenerator expr.WalkMappedAttr(attr, func(name, elem string, hattr *expr.AttributeExpr) error { // nolint: errcheck // Anchor the header example stream to the header identity so the // example survives generator reorderings. - hrand := rand.Field(attr.AttributeExpr, name) + identity := exampleFieldIdentity(parent, name, owner) header := &Header{ - Description: hattr.Description, + Description: values.Description(hattr.AuthoredAttribute(), hattr.Description), Required: hattr.IsRequiredNoDefault(name), - Schema: newSchemafier(hrand).schemafy(hattr), - Example: openapi.Example(hattr, hrand), + Schema: newSchemafier(rand.At(identity), values).schemafy(hattr), Extensions: openapi.ExtensionsFromExpr(hattr.Meta), } - initExamples(header, hattr, hrand) + initExamples(header, hattr, rand.At(identity), values) headers[elem] = &HeaderRef{Value: header} return nil }) return headers } -func responseFromExpr(r *expr.HTTPResponseExpr, bodies map[int][]*openapi.Schema, rand *expr.ExampleGenerator) *Response { - ct := responseContentType(r) - headers := headersFromAttr(r.Headers, rand) - cookies := headersFromAttr(r.Cookies, rand) +func responseFromExpr(r *expr.HTTPResponseExpr, body *openapi.Schema, rand *expr.ExampleGenerator, parent *expr.AttributeExpr, fieldOwner, bodyOwner expr.ExampleIdentity, fallbackDescription string, values openapi.Values) *Response { + ct := openapi.ResponseContentType(r) + headers := headersFromAttr(r.Headers, parent, fieldOwner, rand, values) + cookies := headersFromAttr(r.Cookies, parent, fieldOwner, rand, values) if len(cookies) > 0 { if headers == nil { headers = make(map[string]*HeaderRef) @@ -65,12 +65,10 @@ func responseFromExpr(r *expr.HTTPResponseExpr, bodies map[int][]*openapi.Schema if r.Body.Type != expr.Empty { content = make(map[string]*MediaType) content[ct] = &MediaType{ - Schema: bodies[r.StatusCode][0], + Schema: body, Extensions: openapi.ExtensionsFromExpr(r.Body.Meta), } - ep := r.Parent.(*expr.HTTPEndpointExpr) - id := bodyExampleID(ep.Service.Name(), ep.Name(), "response."+strconv.Itoa(r.StatusCode)+".0") - initExamples(content[ct], staticViewBody(r), rand.Rebased(id)) + initExamples(content[ct], staticViewBody(r), rand.At(bodyOwner), values) } else if r.StatusCode != expr.StatusNoContent && isSkipResponseBodyEncodeDecode(r.Parent) { // When SkipResponseBodyEncodeDecode is declared, the response type @@ -85,7 +83,10 @@ func responseFromExpr(r *expr.HTTPResponseExpr, bodies map[int][]*openapi.Schema } } } - desc := r.Description + desc := values.Description(r, r.Description) + if desc == "" { + desc = fallbackDescription + } if desc == "" { desc = fmt.Sprintf("%s response.", http.StatusText(r.StatusCode)) } @@ -97,28 +98,12 @@ func responseFromExpr(r *expr.HTTPResponseExpr, bodies map[int][]*openapi.Schema } } -// responseContentType computes the content type of the given response: the -// explicitly defined content type if any, the content type of the response -// result type otherwise, defaulting to application/json. The result type is -// the view-projected one when the design pins the response to a single view; -// projected result types carry no content type. -func responseContentType(r *expr.HTTPResponseExpr) string { - if r.ContentType != "" { - return r.ContentType - } - if rt, ok := staticViewBody(r).Type.(*expr.ResultTypeExpr); ok && rt.ContentType != "" { - return rt.ContentType - } - return "application/json" -} - // setSSEContent rewrites the content of a successful server-sent events // response for OpenAPI 3.2 documents. The text/event-stream media type // describes each streamed event with an itemSchema instead of a whole-stream -// schema. When the method defines mixed results (distinct unary and streaming -// result types) the unary result is documented under its own content type -// (ct) next to the event stream to reflect the content negotiation performed -// by the generated handler. +// schema. When the method defines separate normal and streaming results, the +// normal result is documented under its own content type next to the event +// stream to match the response selected by the generated handler. func setSSEContent(resp *Response, bodies *EndpointBodies, ct string, mixed bool) { sse := &MediaType{ItemSchema: bodies.SSEItemSchema} if !mixed { diff --git a/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden b/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden index a3535c11c0..0ce9b4b10b 100644 --- a/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden @@ -5,20 +5,14 @@ "description": "Request body for testEndpoint.", "example": { "completed": [ - "who", - "who", - "who", - "who" + "when" ], "current": "who" }, "properties": { "completed": { "example": [ - "who", - "who", - "who", - "who" + "when" ], "items": { "description": "Setup stage.", @@ -63,10 +57,7 @@ "application/json": { "example": { "completed": [ - "who", - "who", - "who", - "who" + "when" ], "current": "who" }, @@ -84,10 +75,9 @@ "application/json": { "example": { "completed": [ - "where", - "where" + "when" ], - "current": "where" + "current": "who" }, "schema": { "$ref": "#/components/schemas/Setup" diff --git a/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden b/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden index 63ba41157c..8d990da2be 100644 --- a/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden @@ -21,10 +21,7 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - who - - who - - who - - who + - when current: who responses: "200": @@ -35,9 +32,8 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - where - - where - current: where + - when + current: who components: schemas: Setup: @@ -55,10 +51,7 @@ components: - where - what example: - - who - - who - - who - - who + - when current: type: string description: Setup stage. @@ -71,10 +64,7 @@ components: description: Request body for testEndpoint. example: completed: - - who - - who - - who - - who + - when current: who tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/array_file0.golden b/http/codegen/openapi/v3/testdata/golden/array_file0.golden index 73d27f8c36..279166bec7 100644 --- a/http/codegen/openapi/v3/testdata/golden/array_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/array_file0.golden @@ -3,11 +3,11 @@ "schemas": { "Bar": { "example": { - "string": "" + "string": "item" }, "properties": { "string": { - "example": "", + "example": "item", "maxLength": 42, "minLength": 0, "type": "string" @@ -17,15 +17,22 @@ }, "Foobar": { "example": { - "bar": [], + "bar": [ + { + "string": "item" + } + ], "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." + "item" ] }, "properties": { "bar": { - "example": [], + "example": [ + { + "string": "item" + } + ], "items": { "$ref": "#/components/schemas/Bar" }, @@ -35,11 +42,10 @@ }, "foo": { "example": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." + "item" ], "items": { - "example": "Molestiae dolor eveniet omnis atque.", + "example": "item", "type": "string" }, "maxItems": 42, @@ -65,48 +71,26 @@ "application/json": { "example": [ { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] - }, - { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] - }, - { - "bar": [], + "bar": [ + { + "string": "item" + } + ], "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." + "item" ] } ], "schema": { "example": [ { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] - }, - { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] - }, - { - "bar": [], + "bar": [ + { + "string": "item" + } + ], "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." + "item" ] } ], diff --git a/http/codegen/openapi/v3/testdata/golden/array_file1.golden b/http/codegen/openapi/v3/testdata/golden/array_file1.golden index aaaf460f0a..0464761079 100644 --- a/http/codegen/openapi/v3/testdata/golden/array_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/array_file1.golden @@ -21,31 +21,15 @@ paths: items: $ref: '#/components/schemas/Foobar' example: - - bar: [] + - bar: + - string: item foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - item example: - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. - - bar: [] + - bar: + - string: item foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - item responses: "200": description: OK response. @@ -64,11 +48,11 @@ components: properties: string: type: string - example: "" + example: item minLength: 0 maxLength: 42 example: - string: "" + string: item Foobar: type: object properties: @@ -76,23 +60,23 @@ components: type: array items: $ref: '#/components/schemas/Bar' - example: [] + example: + - string: item minItems: 0 maxItems: 42 foo: type: array items: type: string - example: Molestiae dolor eveniet omnis atque. + example: item example: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - item minItems: 0 maxItems: 42 example: - bar: [] + bar: + - string: item foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - item tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/bytes-example_file0.golden b/http/codegen/openapi/v3/testdata/golden/bytes-example_file0.golden new file mode 100644 index 0000000000..dd8979f0f7 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/bytes-example_file0.golden @@ -0,0 +1,44 @@ +{ + "components": {}, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.0.3", + "paths": { + "/download": { + "get": { + "operationId": "bytes#download", + "responses": { + "200": { + "content": { + "application/json": { + "example": "aGVsbG8=", + "schema": { + "example": "aGVsbG8=", + "format": "binary", + "type": "string" + } + } + }, + "description": "OK response." + } + }, + "summary": "download bytes", + "tags": [ + "bytes" + ] + } + } + }, + "servers": [ + { + "url": "https://goa.design" + } + ], + "tags": [ + { + "name": "bytes" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/bytes-example_file1.golden b/http/codegen/openapi/v3/testdata/golden/bytes-example_file1.golden new file mode 100644 index 0000000000..56b5313f7c --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/bytes-example_file1.golden @@ -0,0 +1,26 @@ +openapi: 3.0.3 +info: + title: Goa API + version: 0.0.1 +servers: + - url: https://goa.design +paths: + /download: + get: + tags: + - bytes + summary: download bytes + operationId: bytes#download + responses: + "200": + description: OK response. + content: + application/json: + schema: + type: string + example: aGVsbG8= + format: binary + example: aGVsbG8= +components: {} +tags: + - name: bytes diff --git a/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden b/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden index bbec695a5a..d791d2c180 100644 --- a/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden @@ -4,17 +4,17 @@ "Error": { "description": "Error response result type", "example": { - "fault": true, + "fault": false, "id": "123abc", "message": "parameter 'p' must be an integer", "name": "bad_request", "temporary": true, - "timeout": false + "timeout": true }, "properties": { "fault": { "description": "Is the error a server-side fault?", - "example": true, + "example": false, "type": "boolean" }, "id": { @@ -39,7 +39,7 @@ }, "timeout": { "description": "Is the error a timeout?", - "example": false, + "example": true, "type": "boolean" } }, @@ -110,6 +110,14 @@ "404": { "content": { "application/vnd.goa.error": { + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "not_found", + "temporary": false, + "timeout": false + }, "schema": { "$ref": "#/components/schemas/Error" } @@ -130,6 +138,78 @@ } }, "description": "custom: Conflict response." + }, + "429": { + "content": { + "application/vnd.goa.error": { + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "retry", + "temporary": true, + "timeout": false + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "retry: Too Many Requests response." + }, + "500": { + "content": { + "application/vnd.goa.error": { + "example": { + "fault": true, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "internal", + "temporary": false, + "timeout": false + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "internal: Internal Server Error response." + }, + "503": { + "content": { + "application/vnd.goa.error": { + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "retry_deadline", + "temporary": true, + "timeout": true + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "retry_deadline: Service Unavailable response." + }, + "504": { + "content": { + "application/vnd.goa.error": { + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "deadline", + "temporary": false, + "timeout": true + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "deadline: Gateway Timeout response." } }, "summary": "Error Errors", diff --git a/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden b/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden index d833ea63bd..86b5426dfd 100644 --- a/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden @@ -34,6 +34,13 @@ paths: application/vnd.goa.error: schema: $ref: '#/components/schemas/Error' + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: not_found + temporary: false + timeout: false "409": description: 'custom: Conflict response.' content: @@ -43,6 +50,58 @@ paths: example: message: error message name: custom + "429": + description: 'retry: Too Many Requests response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: retry + temporary: true + timeout: false + "500": + description: 'internal: Internal Server Error response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + example: + fault: true + id: 123abc + message: parameter 'p' must be an integer + name: internal + temporary: false + timeout: false + "503": + description: 'retry_deadline: Service Unavailable response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: retry_deadline + temporary: true + timeout: true + "504": + description: 'deadline: Gateway Timeout response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: deadline + temporary: false + timeout: true components: schemas: Error: @@ -51,7 +110,7 @@ components: fault: type: boolean description: Is the error a server-side fault? - example: true + example: false id: type: string description: ID is a unique identifier for this particular occurrence of the problem. @@ -71,15 +130,15 @@ components: timeout: type: boolean description: Is the error a timeout? - example: false + example: true description: Error response result type example: - fault: true + fault: false id: 123abc message: parameter 'p' must be an integer name: bad_request temporary: true - timeout: false + timeout: true required: - name - id diff --git a/http/codegen/openapi/v3/testdata/golden/headers_file0.golden b/http/codegen/openapi/v3/testdata/golden/headers_file0.golden index 13cba070a2..ff8d2ff822 100644 --- a/http/codegen/openapi/v3/testdata/golden/headers_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/headers_file0.golden @@ -11,21 +11,21 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 8568805688952666000, + "example": 1, "in": "header", "name": "foo", "schema": { - "example": 5490475434297746000, + "example": 1, "format": "int64", "type": "integer" } }, { - "example": 8380651525843656000, + "example": 2, "in": "header", "name": "bar", "schema": { - "example": 1475422799873681700, + "example": 2, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/headers_file1.golden b/http/codegen/openapi/v3/testdata/golden/headers_file1.golden index 24f74063e0..980d5664b4 100644 --- a/http/codegen/openapi/v3/testdata/golden/headers_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/headers_file1.golden @@ -17,16 +17,16 @@ paths: in: header schema: type: integer - example: 5490475434297746524 + example: 1 format: int64 - example: 8568805688952666114 + example: 1 - name: bar in: header schema: type: integer - example: 1475422799873681639 + example: 2 format: int64 - example: 8380651525843655561 + example: 2 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden index 439aa96af1..d370718f73 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden @@ -13,9 +13,9 @@ "200": { "content": { "application/json": { - "example": "Debitis repellendus at repellendus fugit iusto deleniti.", + "example": "ok", "schema": { - "example": "Debitis repellendus at repellendus fugit iusto deleniti.", + "example": "ok", "type": "string" } } diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden index 8b151c2e6c..c8b1817e0b 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden @@ -16,8 +16,8 @@ paths: application/json: schema: type: string - example: Debitis repellendus at repellendus fugit iusto deleniti. - example: Debitis repellendus at repellendus fugit iusto deleniti. + example: ok + example: ok components: {} tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden index 439aa96af1..d370718f73 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden @@ -13,9 +13,9 @@ "200": { "content": { "application/json": { - "example": "Debitis repellendus at repellendus fugit iusto deleniti.", + "example": "ok", "schema": { - "example": "Debitis repellendus at repellendus fugit iusto deleniti.", + "example": "ok", "type": "string" } } diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden index 8b151c2e6c..c8b1817e0b 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden @@ -16,8 +16,8 @@ paths: application/json: schema: type: string - example: Debitis repellendus at repellendus fugit iusto deleniti. - example: Debitis repellendus at repellendus fugit iusto deleniti. + example: ok + example: ok components: {} tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden index 3a95fa8fb2..717d087909 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden @@ -11,23 +11,23 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 8568805688952666000, + "example": 1, "in": "path", "name": "foo", "required": true, "schema": { - "example": 5490475434297746000, + "example": 1, "format": "int64", "type": "integer" } }, { - "example": 8380651525843656000, + "example": 2, "in": "path", "name": "bar", "required": true, "schema": { - "example": 1475422799873681700, + "example": 2, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden index 6cf912a664..b492ef170e 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden @@ -18,17 +18,17 @@ paths: required: true schema: type: integer - example: 5490475434297746524 + example: 1 format: int64 - example: 8568805688952666114 + example: 1 - name: bar in: path required: true schema: type: integer - example: 1475422799873681639 + example: 2 format: int64 - example: 8380651525843655561 + example: 2 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden index 3a95fa8fb2..717d087909 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden @@ -11,23 +11,23 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 8568805688952666000, + "example": 1, "in": "path", "name": "foo", "required": true, "schema": { - "example": 5490475434297746000, + "example": 1, "format": "int64", "type": "integer" } }, { - "example": 8380651525843656000, + "example": 2, "in": "path", "name": "bar", "required": true, "schema": { - "example": 1475422799873681700, + "example": 2, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden index 6cf912a664..b492ef170e 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden @@ -18,17 +18,17 @@ paths: required: true schema: type: integer - example: 5490475434297746524 + example: 1 format: int64 - example: 8568805688952666114 + example: 1 - name: bar in: path required: true schema: type: integer - example: 1475422799873681639 + example: 2 format: int64 - example: 8380651525843655561 + example: 2 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden index de0170e414..f9d8ece6a3 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden @@ -11,12 +11,12 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 5691356309313629000, + "example": 1, "in": "path", "name": "int_map", "required": true, "schema": { - "example": 4595362125781949000, + "example": 1, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden index df365d17e6..e25a083b6d 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden @@ -18,9 +18,9 @@ paths: required: true schema: type: integer - example: 4595362125781948859 + example: 1 format: int64 - example: 5691356309313628853 + example: 1 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file0.golden b/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file0.golden new file mode 100644 index 0000000000..d071c8544e --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file0.golden @@ -0,0 +1,175 @@ +{ + "components": { + "schemas": { + "StoredBottleResponse": { + "description": "StoredBottle result type (default view)", + "example": { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + "properties": { + "name": { + "example": "Blue's Cuvee", + "type": "string" + }, + "vintage": { + "example": 2003, + "format": "int32", + "type": "integer" + } + }, + "required": [ + "name", + "vintage" + ], + "type": "object" + }, + "StoredBottleResponseCollection": { + "description": "list_default_response_body is the result type for an array of StoredBottle (default view)", + "example": [ + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + } + ], + "items": { + "$ref": "#/components/schemas/StoredBottleResponse" + }, + "type": "array" + }, + "StoredBottleResponseTiny": { + "description": "StoredBottle result type (tiny view)", + "example": { + "name": "Blue's Cuvee" + }, + "properties": { + "name": { + "example": "Blue's Cuvee", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "StoredBottleResponseTinyCollection": { + "description": "list_tiny_response_body is the result type for an array of StoredBottle (tiny view)", + "example": [ + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + } + ], + "items": { + "$ref": "#/components/schemas/StoredBottleResponseTiny" + }, + "type": "array" + } + } + }, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.0.3", + "paths": { + "/default": { + "get": { + "operationId": "storage#list_default", + "responses": { + "200": { + "content": { + "application/json": { + "example": [ + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + } + ], + "schema": { + "$ref": "#/components/schemas/StoredBottleResponseCollection" + } + } + }, + "description": "OK response." + } + }, + "summary": "list_default storage", + "tags": [ + "storage" + ] + } + }, + "/tiny": { + "get": { + "operationId": "storage#list_tiny", + "responses": { + "200": { + "content": { + "application/json": { + "example": [ + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + } + ], + "schema": { + "$ref": "#/components/schemas/StoredBottleResponseTinyCollection" + } + } + }, + "description": "OK response." + } + }, + "summary": "list_tiny storage", + "tags": [ + "storage" + ] + } + } + }, + "servers": [ + { + "description": "Default server for test api", + "url": "http://localhost:80" + } + ], + "tags": [ + { + "name": "storage" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file1.golden b/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file1.golden new file mode 100644 index 0000000000..7cf5b12f9a --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file1.golden @@ -0,0 +1,100 @@ +openapi: 3.0.3 +info: + title: Goa API + version: 0.0.1 +servers: + - url: http://localhost:80 + description: Default server for test api +paths: + /default: + get: + tags: + - storage + summary: list_default storage + operationId: storage#list_default + responses: + "200": + description: OK response. + content: + application/json: + schema: + $ref: '#/components/schemas/StoredBottleResponseCollection' + example: + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + /tiny: + get: + tags: + - storage + summary: list_tiny storage + operationId: storage#list_tiny + responses: + "200": + description: OK response. + content: + application/json: + schema: + $ref: '#/components/schemas/StoredBottleResponseTinyCollection' + example: + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee +components: + schemas: + StoredBottleResponse: + type: object + properties: + name: + type: string + example: Blue's Cuvee + vintage: + type: integer + example: 2003 + format: int32 + description: StoredBottle result type (default view) + example: + name: Blue's Cuvee + vintage: 2003 + required: + - name + - vintage + StoredBottleResponseCollection: + type: array + items: + $ref: '#/components/schemas/StoredBottleResponse' + description: list_default_response_body is the result type for an array of StoredBottle (default view) + example: + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + StoredBottleResponseTiny: + type: object + properties: + name: + type: string + example: Blue's Cuvee + description: StoredBottle result type (tiny view) + example: + name: Blue's Cuvee + required: + - name + StoredBottleResponseTinyCollection: + type: array + items: + $ref: '#/components/schemas/StoredBottleResponseTiny' + description: list_tiny_response_body is the result type for an array of StoredBottle (tiny view) + example: + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee +tags: + - name: storage diff --git a/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file0.golden b/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file0.golden index 675ba8cf8b..438bf890a6 100644 --- a/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file0.golden @@ -26,7 +26,8 @@ "url": "https://{version}.goa.design", "variables": { "version": { - "default": "v1" + "default": "v1", + "description": "API Version" } } } diff --git a/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file1.golden b/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file1.golden index 29e3c33130..b13193e3f8 100644 --- a/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file1.golden @@ -7,6 +7,7 @@ servers: variables: version: default: v1 + description: API Version paths: /: post: diff --git a/http/codegen/openapi/v3/testdata/golden/shared-error-description_file0.golden b/http/codegen/openapi/v3/testdata/golden/shared-error-description_file0.golden new file mode 100644 index 0000000000..ae875bb90c --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/shared-error-description_file0.golden @@ -0,0 +1,95 @@ +{ + "components": { + "schemas": { + "SharedError": { + "description": "Shared error value", + "example": { + "message": "shared failure" + }, + "properties": { + "message": { + "description": "Error message", + "example": "shared failure", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.0.3", + "paths": { + "/first": { + "get": { + "operationId": "errors#first", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "content": { + "application/json": { + "example": { + "message": "shared failure" + }, + "schema": { + "$ref": "#/components/schemas/SharedError" + } + } + }, + "description": "first_error: First failure" + } + }, + "summary": "first errors", + "tags": [ + "errors" + ] + } + }, + "/second": { + "get": { + "operationId": "errors#second", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "content": { + "application/json": { + "example": { + "message": "shared failure" + }, + "schema": { + "$ref": "#/components/schemas/SharedError" + } + } + }, + "description": "second_error: Second failure" + } + }, + "summary": "second errors", + "tags": [ + "errors" + ] + } + } + }, + "servers": [ + { + "description": "Default server for test api", + "url": "http://localhost:80" + } + ], + "tags": [ + { + "name": "errors" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/shared-error-description_file1.golden b/http/codegen/openapi/v3/testdata/golden/shared-error-description_file1.golden new file mode 100644 index 0000000000..f351f7d4f5 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/shared-error-description_file1.golden @@ -0,0 +1,58 @@ +openapi: 3.0.3 +info: + title: Goa API + version: 0.0.1 +servers: + - url: http://localhost:80 + description: Default server for test api +paths: + /first: + get: + tags: + - errors + summary: first errors + operationId: errors#first + responses: + "204": + description: No Content response. + "400": + description: 'first_error: First failure' + content: + application/json: + schema: + $ref: '#/components/schemas/SharedError' + example: + message: shared failure + /second: + get: + tags: + - errors + summary: second errors + operationId: errors#second + responses: + "204": + description: No Content response. + "400": + description: 'second_error: Second failure' + content: + application/json: + schema: + $ref: '#/components/schemas/SharedError' + example: + message: shared failure +components: + schemas: + SharedError: + type: object + properties: + message: + type: string + description: Error message + example: shared failure + description: Shared error value + example: + message: shared failure + required: + - message +tags: + - name: errors diff --git a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden index 8c247e2e1b..dd5305c3da 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden @@ -4,11 +4,11 @@ "SSEAllFieldsMethodRequestBody": { "description": "Request body for SSEAllFieldsMethod.", "example": { - "id": "Ducimus vel." + "id": "request" }, "properties": { "id": { - "example": "Ducimus vel.", + "example": "request", "type": "string" } }, @@ -67,7 +67,7 @@ "content": { "application/json": { "example": { - "id": "Ducimus vel." + "id": "request" }, "schema": { "$ref": "#/components/schemas/SSEAllFieldsMethodRequestBody" diff --git a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden index 53ba9572b0..25de2eaf3c 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/SSEAllFieldsMethodRequestBody' example: - id: Ducimus vel. + id: request responses: "200": description: OK response. @@ -41,10 +41,10 @@ components: properties: id: type: string - example: Ducimus vel. + example: request description: Request body for SSEAllFieldsMethod. example: - id: Ducimus vel. + id: request SSEAllFieldsMethodResponseBody: type: object properties: diff --git a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden index ff7f14052a..82dbcf0fe9 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden @@ -4,11 +4,11 @@ "Payload": { "description": "Request body for Create.", "example": { - "x": "Doloribus qui aspernatur alias consectetur accusamus qui." + "x": "request" }, "properties": { "x": { - "example": "Doloribus qui aspernatur alias consectetur accusamus qui.", + "example": "request", "type": "string" } }, @@ -19,11 +19,11 @@ }, "Result": { "example": { - "id": "Tenetur aut quam ea repudiandae." + "id": "result" }, "properties": { "id": { - "example": "Tenetur aut quam ea repudiandae.", + "example": "result", "type": "string" } }, @@ -47,7 +47,7 @@ "content": { "application/json": { "example": { - "x": "Doloribus qui aspernatur alias consectetur accusamus qui." + "x": "request" }, "schema": { "$ref": "#/components/schemas/Payload" @@ -62,7 +62,7 @@ "content": { "text/event-stream": { "example": { - "id": "Tenetur aut quam ea repudiandae." + "id": "result" }, "schema": { "$ref": "#/components/schemas/Result" diff --git a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden index 3b5145ddb9..9700c24d35 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Payload' example: - x: Doloribus qui aspernatur alias consectetur accusamus qui. + x: request responses: "200": description: OK response. @@ -29,7 +29,7 @@ paths: schema: $ref: '#/components/schemas/Result' example: - id: Tenetur aut quam ea repudiandae. + id: result components: schemas: Payload: @@ -37,10 +37,10 @@ components: properties: x: type: string - example: Doloribus qui aspernatur alias consectetur accusamus qui. + example: request description: Request body for Create. example: - x: Doloribus qui aspernatur alias consectetur accusamus qui. + x: request required: - x Result: @@ -48,9 +48,9 @@ components: properties: id: type: string - example: Tenetur aut quam ea repudiandae. + example: result example: - id: Tenetur aut quam ea repudiandae. + id: result required: - id tags: diff --git a/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden b/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden index c2465ac9ea..1c30e65baf 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden @@ -13,9 +13,9 @@ "200": { "content": { "text/event-stream": { - "example": "Adipisci necessitatibus enim voluptas asperiores corporis.", + "example": "event", "schema": { - "example": "Adipisci necessitatibus enim voluptas asperiores corporis.", + "example": "event", "type": "string" } } diff --git a/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden b/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden index a078bc4adf..2eb08cbbbe 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden @@ -19,8 +19,8 @@ paths: text/event-stream: schema: type: string - example: Adipisci necessitatibus enim voluptas asperiores corporis. - example: Adipisci necessitatibus enim voluptas asperiores corporis. + example: event + example: event components: {} tags: - name: SSEStringService diff --git a/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden b/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden index 60ad86fea6..7129eb7ec8 100644 --- a/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden @@ -4,11 +4,11 @@ "Notification": { "description": "Request body for testEndpoint.", "example": { - "id": "Quia velit et." + "id": "notice" }, "properties": { "id": { - "example": "Quia velit et.", + "example": "notice", "type": "string" } }, @@ -30,7 +30,7 @@ "content": { "application/json": { "example": { - "id": "Quia velit et." + "id": "notice" }, "schema": { "$ref": "#/components/schemas/Notification" @@ -45,7 +45,7 @@ "content": { "application/json": { "example": { - "id": "Aut soluta voluptatem nisi corrupti." + "id": "notice" }, "schema": { "$ref": "#/components/schemas/Notification" diff --git a/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden b/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden index 615cb69668..03415ac180 100644 --- a/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Notification' example: - id: Quia velit et. + id: notice responses: "200": description: OK response. @@ -29,16 +29,16 @@ paths: schema: $ref: '#/components/schemas/Notification' example: - id: Aut soluta voluptatem nisi corrupti. + id: notice components: schemas: Notification: description: Request body for testEndpoint. example: - id: Quia velit et. + id: notice properties: id: - example: Quia velit et. + example: notice type: string type: object x-test-include: true diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden index f5160a5df7..7d3973366b 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden @@ -5,20 +5,14 @@ "description": "Request body for testEndpoint.", "example": { "completed": [ - "who", - "who", - "who", - "who" + "when" ], "current": "who" }, "properties": { "completed": { "example": [ - "who", - "who", - "who", - "who" + "when" ], "items": { "$ref": "#/components/schemas/Stage" @@ -58,10 +52,7 @@ "application/json": { "example": { "completed": [ - "who", - "who", - "who", - "who" + "when" ], "current": "who" }, @@ -79,10 +70,9 @@ "application/json": { "example": { "completed": [ - "where", - "where" + "when" ], - "current": "where" + "current": "who" }, "schema": { "$ref": "#/components/schemas/Setup" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden index 36fe6cd2b8..f768eeb2fe 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden @@ -22,10 +22,7 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - who - - who - - who - - who + - when current: who responses: "200": @@ -36,9 +33,8 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - where - - where - current: where + - when + current: who components: schemas: Setup: @@ -49,19 +45,13 @@ components: items: $ref: '#/components/schemas/Stage' example: - - who - - who - - who - - who + - when current: $ref: '#/components/schemas/Stage' description: Request body for testEndpoint. example: completed: - - who - - who - - who - - who + - when current: who Stage: type: string diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file0.golden new file mode 100644 index 0000000000..c4ace5c082 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file0.golden @@ -0,0 +1,45 @@ +{ + "components": {}, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.2.0", + "paths": { + "/download": { + "get": { + "operationId": "bytes#download", + "responses": { + "200": { + "content": { + "application/json": { + "example": "aGVsbG8=", + "schema": { + "example": "aGVsbG8=", + "format": "binary", + "type": "string" + } + } + }, + "description": "OK response." + } + }, + "summary": "download bytes", + "tags": [ + "bytes" + ] + } + } + }, + "servers": [ + { + "name": "bytes", + "url": "https://goa.design" + } + ], + "tags": [ + { + "name": "bytes" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file1.golden new file mode 100644 index 0000000000..b0019fd577 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file1.golden @@ -0,0 +1,27 @@ +openapi: 3.2.0 +info: + title: Goa API + version: 0.0.1 +servers: + - url: https://goa.design + name: bytes +paths: + /download: + get: + tags: + - bytes + summary: download bytes + operationId: bytes#download + responses: + "200": + description: OK response. + content: + application/json: + schema: + type: string + example: aGVsbG8= + format: binary + example: aGVsbG8= +components: {} +tags: + - name: bytes diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file0.golden index b8739c4fa3..fe2fc87557 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file0.golden @@ -27,7 +27,8 @@ "url": "https://{version}.goa.design", "variables": { "version": { - "default": "v1" + "default": "v1", + "description": "API Version" } } } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file1.golden index 41168294ca..6b3dd5b102 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file1.golden @@ -8,6 +8,7 @@ servers: variables: version: default: v1 + description: API Version paths: /: post: diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file0.golden new file mode 100644 index 0000000000..ca953baf3f --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file0.golden @@ -0,0 +1,96 @@ +{ + "components": { + "schemas": { + "SharedError": { + "description": "Shared error value", + "example": { + "message": "shared failure" + }, + "properties": { + "message": { + "description": "Error message", + "example": "shared failure", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.2.0", + "paths": { + "/first": { + "get": { + "operationId": "errors#first", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "content": { + "application/json": { + "example": { + "message": "shared failure" + }, + "schema": { + "$ref": "#/components/schemas/SharedError" + } + } + }, + "description": "first_error: First failure" + } + }, + "summary": "first errors", + "tags": [ + "errors" + ] + } + }, + "/second": { + "get": { + "operationId": "errors#second", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "content": { + "application/json": { + "example": { + "message": "shared failure" + }, + "schema": { + "$ref": "#/components/schemas/SharedError" + } + } + }, + "description": "second_error: Second failure" + } + }, + "summary": "second errors", + "tags": [ + "errors" + ] + } + } + }, + "servers": [ + { + "description": "Default server for test api", + "name": "test api", + "url": "http://localhost:80" + } + ], + "tags": [ + { + "name": "errors" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file1.golden new file mode 100644 index 0000000000..96583d0e28 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file1.golden @@ -0,0 +1,59 @@ +openapi: 3.2.0 +info: + title: Goa API + version: 0.0.1 +servers: + - url: http://localhost:80 + name: test api + description: Default server for test api +paths: + /first: + get: + tags: + - errors + summary: first errors + operationId: errors#first + responses: + "204": + description: No Content response. + "400": + description: 'first_error: First failure' + content: + application/json: + schema: + $ref: '#/components/schemas/SharedError' + example: + message: shared failure + /second: + get: + tags: + - errors + summary: second errors + operationId: errors#second + responses: + "204": + description: No Content response. + "400": + description: 'second_error: Second failure' + content: + application/json: + schema: + $ref: '#/components/schemas/SharedError' + example: + message: shared failure +components: + schemas: + SharedError: + type: object + properties: + message: + type: string + description: Error message + example: shared failure + description: Shared error value + example: + message: shared failure + required: + - message +tags: + - name: errors diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden index d0b6443840..971763a0a3 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden @@ -4,11 +4,11 @@ "SSEAllFieldsMethodRequestBody": { "description": "Request body for SSEAllFieldsMethod.", "example": { - "id": "Ducimus vel." + "id": "request" }, "properties": { "id": { - "example": "Ducimus vel.", + "example": "request", "type": "string" } }, @@ -65,11 +65,11 @@ "operationId": "SSEAllFieldsService#SSEAllFieldsMethod", "parameters": [ { - "example": "Non sed saepe voluptatem.", + "example": "request", "in": "header", "name": "Last-Event-ID", "schema": { - "example": "Natus magni voluptates consequatur suscipit.", + "example": "request", "type": "string" } } @@ -78,7 +78,7 @@ "content": { "application/json": { "example": { - "id": "Ducimus vel." + "id": "request" }, "schema": { "$ref": "#/components/schemas/SSEAllFieldsMethodRequestBody" @@ -124,9 +124,6 @@ "type": "integer" } }, - "required": [ - "data" - ], "type": "object" } } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden index de1487fe6c..ad37e7772f 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden @@ -18,8 +18,8 @@ paths: in: header schema: type: string - example: Natus magni voluptates consequatur suscipit. - example: Non sed saepe voluptatem. + example: request + example: request requestBody: description: Request body for SSEAllFieldsMethod. required: true @@ -28,7 +28,7 @@ paths: schema: $ref: '#/components/schemas/SSEAllFieldsMethodRequestBody' example: - id: Ducimus vel. + id: request responses: "200": description: OK response. @@ -58,8 +58,6 @@ paths: type: integer example: 3000 format: int64 - required: - - data components: schemas: SSEAllFieldsMethodRequestBody: @@ -67,10 +65,10 @@ components: properties: id: type: string - example: Ducimus vel. + example: request description: Request body for SSEAllFieldsMethod. example: - id: Ducimus vel. + id: request SSEAllFieldsMethodResponseBody: type: object properties: diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden index 79a3a87fd9..3bcde15c46 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden @@ -3,12 +3,12 @@ "schemas": { "SSEDataFieldMethodResponseBody": { "example": { - "data": "Ipsum voluptatem quaerat quo et non sed.", + "data": "event", "flag": true }, "properties": { "data": { - "example": "Ipsum voluptatem quaerat quo et non sed.", + "example": "event", "type": "string" }, "flag": { @@ -36,13 +36,10 @@ "itemSchema": { "properties": { "data": { - "example": "Quos qui dolore voluptas.", + "example": "event", "type": "string" } }, - "required": [ - "data" - ], "type": "object" } } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden index 36a166ec7c..750c350da1 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden @@ -23,9 +23,7 @@ paths: properties: data: type: string - example: Quos qui dolore voluptas. - required: - - data + example: event components: schemas: SSEDataFieldMethodResponseBody: @@ -33,12 +31,12 @@ components: properties: data: type: string - example: Ipsum voluptatem quaerat quo et non sed. + example: event flag: type: boolean example: true example: - data: Ipsum voluptatem quaerat quo et non sed. + data: event flag: true tags: - name: SSEDataFieldService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden index bfffbfb0ed..6814d282ca 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden @@ -3,11 +3,11 @@ "schemas": { "Event": { "example": { - "message": "Doloribus nemo quia dolores." + "message": "event" }, "properties": { "message": { - "example": "Doloribus nemo quia dolores.", + "example": "event", "type": "string" } }, @@ -19,11 +19,11 @@ "Payload": { "description": "Request body for Create.", "example": { - "x": "Doloribus qui aspernatur alias consectetur accusamus qui." + "x": "request" }, "properties": { "x": { - "example": "Doloribus qui aspernatur alias consectetur accusamus qui.", + "example": "request", "type": "string" } }, @@ -34,11 +34,11 @@ }, "Result": { "example": { - "id": "Tenetur aut quam ea repudiandae." + "id": "result" }, "properties": { "id": { - "example": "Tenetur aut quam ea repudiandae.", + "example": "result", "type": "string" } }, @@ -62,7 +62,7 @@ "content": { "application/json": { "example": { - "x": "Doloribus qui aspernatur alias consectetur accusamus qui." + "x": "request" }, "schema": { "$ref": "#/components/schemas/Payload" @@ -77,7 +77,7 @@ "content": { "application/json": { "example": { - "id": "Tenetur aut quam ea repudiandae." + "id": "result" }, "schema": { "$ref": "#/components/schemas/Result" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden index aa5911777e..12254d17f7 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden @@ -21,7 +21,7 @@ paths: schema: $ref: '#/components/schemas/Payload' example: - x: Doloribus qui aspernatur alias consectetur accusamus qui. + x: request responses: "200": description: OK response. @@ -30,7 +30,7 @@ paths: schema: $ref: '#/components/schemas/Result' example: - id: Tenetur aut quam ea repudiandae. + id: result text/event-stream: itemSchema: type: object @@ -49,9 +49,9 @@ components: properties: message: type: string - example: Doloribus nemo quia dolores. + example: event example: - message: Doloribus nemo quia dolores. + message: event required: - message Payload: @@ -59,10 +59,10 @@ components: properties: x: type: string - example: Doloribus qui aspernatur alias consectetur accusamus qui. + example: request description: Request body for Create. example: - x: Doloribus qui aspernatur alias consectetur accusamus qui. + x: request required: - x Result: @@ -70,9 +70,9 @@ components: properties: id: type: string - example: Tenetur aut quam ea repudiandae. + example: result example: - id: Tenetur aut quam ea repudiandae. + id: result required: - id tags: diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden index 603a5cdc19..c185b5d794 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden @@ -4,8 +4,8 @@ "SSEObjectMethodResponseBody": { "example": { "flag": true, - "id": "Aspernatur dolorem velit tenetur.", - "value": 3199557017606053400 + "id": "event", + "value": 1 }, "properties": { "flag": { @@ -13,11 +13,11 @@ "type": "boolean" }, "id": { - "example": "Aspernatur dolorem velit tenetur.", + "example": "event", "type": "string" }, "value": { - "example": 3199557017606053400, + "example": 1, "format": "int64", "type": "integer" } @@ -45,21 +45,21 @@ "contentMediaType": "application/json", "contentSchema": { "example": { - "flag": false, - "id": "Consequuntur dolores eos voluptatem.", - "value": 7986166745691455000 + "flag": true, + "id": "event", + "value": 1 }, "properties": { "flag": { - "example": false, + "example": true, "type": "boolean" }, "id": { - "example": "Consequuntur dolores eos voluptatem.", + "example": "event", "type": "string" }, "value": { - "example": 7986166745691455000, + "example": 1, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden index 6701eb1b08..8878e1bf5a 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden @@ -29,18 +29,18 @@ paths: properties: flag: type: boolean - example: false + example: true id: type: string - example: Consequuntur dolores eos voluptatem. + example: event value: type: integer - example: 7986166745691455230 + example: 1 format: int64 example: - flag: false - id: Consequuntur dolores eos voluptatem. - value: 7986166745691455230 + flag: true + id: event + value: 1 required: - data components: @@ -53,14 +53,14 @@ components: example: true id: type: string - example: Aspernatur dolorem velit tenetur. + example: event value: type: integer - example: 3199557017606053617 + example: 1 format: int64 example: flag: true - id: Aspernatur dolorem velit tenetur. - value: 3199557017606053617 + id: event + value: 1 tags: - name: SSEObjectService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden index ea60200bca..a8a12c2613 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden @@ -4,11 +4,11 @@ "SSERequestIDMethodRequestBody": { "description": "Request body for SSERequestIDMethod.", "example": { - "id": "Est voluptas est repellat." + "id": "request" }, "properties": { "id": { - "example": "Est voluptas est repellat.", + "example": "request", "type": "string" } }, @@ -27,11 +27,11 @@ "operationId": "SSERequestIDService#SSERequestIDMethod", "parameters": [ { - "example": "Non sed saepe voluptatem.", + "example": "request", "in": "header", "name": "Last-Event-ID", "schema": { - "example": "Natus magni voluptates consequatur suscipit.", + "example": "request", "type": "string" } } @@ -40,7 +40,7 @@ "content": { "application/json": { "example": { - "id": "Est voluptas est repellat." + "id": "request" }, "schema": { "$ref": "#/components/schemas/SSERequestIDMethodRequestBody" @@ -57,7 +57,7 @@ "itemSchema": { "properties": { "data": { - "example": "Quia molestias.", + "example": "event", "type": "string" } }, diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden index bbd022e6c6..7f4618ddcc 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden @@ -18,8 +18,8 @@ paths: in: header schema: type: string - example: Natus magni voluptates consequatur suscipit. - example: Non sed saepe voluptatem. + example: request + example: request requestBody: description: Request body for SSERequestIDMethod. required: true @@ -28,7 +28,7 @@ paths: schema: $ref: '#/components/schemas/SSERequestIDMethodRequestBody' example: - id: Est voluptas est repellat. + id: request responses: "200": description: OK response. @@ -39,7 +39,7 @@ paths: properties: data: type: string - example: Quia molestias. + example: event required: - data components: @@ -49,9 +49,9 @@ components: properties: id: type: string - example: Est voluptas est repellat. + example: request description: Request body for SSERequestIDMethod. example: - id: Est voluptas est repellat. + id: request tags: - name: SSERequestIDService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden index b23d98992c..be45a26deb 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden @@ -16,7 +16,7 @@ "itemSchema": { "properties": { "data": { - "example": "Quia molestias.", + "example": "event", "type": "string" } }, diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden index c5fd93dd0b..12076372c5 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden @@ -23,7 +23,7 @@ paths: properties: data: type: string - example: Quia molestias. + example: event required: - data components: {} diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden index a7b20cdb5e..aa1883c165 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden @@ -3,11 +3,11 @@ "schemas": { "UserType": { "example": { - "a": "Odio laborum quae ut quis nostrum." + "a": "event" }, "properties": { "a": { - "example": "Odio laborum quae ut quis nostrum.", + "example": "event", "type": "string" } }, @@ -26,12 +26,12 @@ "operationId": "StreamingResultService#StreamingResultMethod", "parameters": [ { - "example": "Et et quis sint ipsam doloribus.", + "example": "request", "in": "path", "name": "x", "required": true, "schema": { - "example": "Ipsam ut similique tempore.", + "example": "request", "type": "string" } } @@ -41,7 +41,7 @@ "content": { "application/json": { "example": { - "a": "Odio laborum quae ut quis nostrum." + "a": "event" }, "schema": { "$ref": "#/components/schemas/UserType" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden index b556fec6fc..c4d34fc79b 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden @@ -19,8 +19,8 @@ paths: required: true schema: type: string - example: Ipsam ut similique tempore. - example: Et et quis sint ipsam doloribus. + example: request + example: request responses: "101": description: Switching Protocols response. @@ -29,7 +29,7 @@ paths: schema: $ref: '#/components/schemas/UserType' example: - a: Odio laborum quae ut quis nostrum. + a: event components: schemas: UserType: @@ -37,8 +37,8 @@ components: properties: a: type: string - example: Odio laborum quae ut quis nostrum. + example: event example: - a: Odio laborum quae ut quis nostrum. + a: event tags: - name: StreamingResultService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden index 971bde70a4..270b353dce 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden @@ -11,12 +11,12 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 5691356309313629000, + "example": 1, "in": "path", "name": "int_map", "required": true, "schema": { - "example": 4595362125781949000, + "example": 1, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden index e7365c3307..caa349923d 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden @@ -19,9 +19,9 @@ paths: required: true schema: type: integer - example: 4595362125781948859 + example: 1 format: int64 - example: 5691356309313628853 + example: 1 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden b/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden index 3d5a4c31f7..d139d0cf91 100644 --- a/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden @@ -3,11 +3,11 @@ "schemas": { "UserType": { "example": { - "a": "Odio laborum quae ut quis nostrum." + "a": "event" }, "properties": { "a": { - "example": "Odio laborum quae ut quis nostrum.", + "example": "event", "type": "string" } }, @@ -26,12 +26,12 @@ "operationId": "StreamingResultService#StreamingResultMethod", "parameters": [ { - "example": "Et et quis sint ipsam doloribus.", + "example": "request", "in": "path", "name": "x", "required": true, "schema": { - "example": "Ipsam ut similique tempore.", + "example": "request", "type": "string" } } @@ -41,7 +41,7 @@ "content": { "application/json": { "example": { - "a": "Odio laborum quae ut quis nostrum." + "a": "event" }, "schema": { "$ref": "#/components/schemas/UserType" diff --git a/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden b/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden index 3fccfc7691..f16d59a634 100644 --- a/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden @@ -18,8 +18,8 @@ paths: required: true schema: type: string - example: Ipsam ut similique tempore. - example: Et et quis sint ipsam doloribus. + example: request + example: request responses: "101": description: Switching Protocols response. @@ -28,7 +28,7 @@ paths: schema: $ref: '#/components/schemas/UserType' example: - a: Odio laborum quae ut quis nostrum. + a: event components: schemas: UserType: @@ -36,8 +36,8 @@ components: properties: a: type: string - example: Odio laborum quae ut quis nostrum. + example: event example: - a: Odio laborum quae ut quis nostrum. + a: event tags: - name: StreamingResultService diff --git a/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden index 18343b5c9e..46df3c525b 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden @@ -6,12 +6,10 @@ "example": { "any": "", "any_array": [ - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "properties": { @@ -20,8 +18,6 @@ }, "any_array": { "example": [ - "", - "", "" ], "items": { @@ -32,7 +28,7 @@ "any_map": { "additionalProperties": true, "example": { - "": "" + "key": "" }, "type": "object" } @@ -56,12 +52,10 @@ "example": { "any": "", "any_array": [ - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "schema": { @@ -79,13 +73,10 @@ "example": { "any": "", "any_array": [ - "", - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "schema": { diff --git a/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden index cb2e05152e..dac21362b4 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden @@ -23,10 +23,8 @@ paths: any: "" any_array: - "" - - "" - - "" any_map: - "": "" + key: "" responses: "200": description: OK response. @@ -38,11 +36,8 @@ paths: any: "" any_array: - "" - - "" - - "" - - "" any_map: - "": "" + key: "" components: schemas: TestEndpointRequestBody: @@ -56,21 +51,17 @@ components: example: "" example: - "" - - "" - - "" any_map: type: object example: - "": "" + key: "" additionalProperties: true description: Request body for testEndpoint. example: any: "" any_array: - "" - - "" - - "" any_map: - "": "" + key: "" tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/with-map_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-map_file0.golden index 12b5b2d556..81543dbb8b 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-map_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-map_file0.golden @@ -19,6 +19,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -31,6 +34,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -107,6 +113,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -132,6 +141,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -212,6 +224,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } diff --git a/http/codegen/openapi/v3/testdata/golden/with-map_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-map_file1.golden index 14e848014a..5f2c43bcf5 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-map_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-map_file1.golden @@ -40,6 +40,7 @@ paths: bar: - string: "" - string: "" + - string: "" foo: "" uint32_map: "": 1 @@ -65,6 +66,7 @@ components: example: - string: "" - string: "" + - string: "" foo: type: string example: "" @@ -72,6 +74,7 @@ components: bar: - string: "" - string: "" + - string: "" foo: "" TestEndpointRequestBody: type: object @@ -118,6 +121,7 @@ components: bar: - string: "" - string: "" + - string: "" foo: "" additionalProperties: $ref: '#/components/schemas/GoaFoobar' @@ -143,6 +147,7 @@ components: bar: - string: "" - string: "" + - string: "" foo: "" uint32_map: "": 1 diff --git a/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden index 683e7e562a..198823c0c4 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden @@ -4,7 +4,7 @@ "Bar": { "description": "Request body for test endpoint.", "example": { - "string": "" + "string": "item" }, "properties": { "string": { @@ -18,10 +18,7 @@ "example": { "bar": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "foo": "" @@ -30,10 +27,7 @@ "bar": { "example": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "items": { @@ -63,7 +57,7 @@ "content": { "application/json": { "example": { - "string": "" + "string": "item" }, "schema": { "$ref": "#/components/schemas/Bar" @@ -77,19 +71,18 @@ "200": { "content": { "application/json": { - "example": { - "bar": [ - { - "string": "" - }, - { - "string": "" - }, - { - "string": "" + "examples": { + "default": { + "summary": "default", + "value": { + "bar": [ + { + "string": "item" + } + ], + "foo": "" } - ], - "foo": "" + } }, "schema": { "$ref": "#/components/schemas/GoaFoobar" @@ -101,19 +94,18 @@ "404": { "content": { "application/json": { - "example": { - "bar": [ - { - "string": "" - }, - { - "string": "" - }, - { - "string": "" + "examples": { + "default": { + "summary": "default", + "value": { + "bar": [ + { + "string": "item" + } + ], + "foo": "" } - ], - "foo": "" + } }, "schema": { "$ref": "#/components/schemas/GoaFoobar" diff --git a/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden index 28ea024cc9..d4b1d7e703 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Bar' example: - string: "" + string: item responses: "200": description: OK response. @@ -28,24 +28,26 @@ paths: application/json: schema: $ref: '#/components/schemas/GoaFoobar' - example: - bar: - - string: "" - - string: "" - - string: "" - foo: "" + examples: + default: + summary: default + value: + bar: + - string: item + foo: "" "404": description: Not Found response. content: application/json: schema: $ref: '#/components/schemas/GoaFoobar' - example: - bar: - - string: "" - - string: "" - - string: "" - foo: "" + examples: + default: + summary: default + value: + bar: + - string: item + foo: "" components: schemas: Bar: @@ -56,7 +58,7 @@ components: example: "" description: Request body for test endpoint. example: - string: "" + string: item GoaFoobar: type: object properties: @@ -65,15 +67,13 @@ components: items: $ref: '#/components/schemas/Bar' example: - - string: "" - - string: "" + - string: item foo: type: string example: "" example: bar: - - string: "" - - string: "" + - string: item foo: "" tags: - name: test service diff --git a/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden index e08c5e2a30..b17a1563d4 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden @@ -11,12 +11,12 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 5691356309313629000, + "example": 1, "in": "path", "name": "int_map", "required": true, "schema": { - "example": 4595362125781949000, + "example": 1, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden index c736bebb0d..cb444d1caf 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden @@ -18,9 +18,9 @@ paths: required: true schema: type: integer - example: 4595362125781948859 + example: 1 format: int64 - example: 5691356309313628853 + example: 1 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/types.go b/http/codegen/openapi/v3/types.go index ce258de253..2cef6e07e4 100644 --- a/http/codegen/openapi/v3/types.go +++ b/http/codegen/openapi/v3/types.go @@ -1,3 +1,5 @@ +// This file converts evaluated Goa types into OpenAPI v3 schemas while keeping +// generated examples anchored to their exact design locations. package openapiv3 import ( @@ -5,6 +7,7 @@ import ( "fmt" "hash" "hash/fnv" + "slices" "strconv" "strings" @@ -41,7 +44,10 @@ type ( schemas map[string]*openapi.Schema // type names indexed by hashes hashes map[uint64][]string - rand *expr.ExampleGenerator + // released response names indexed by schema hash + preferredNames map[uint64][]string + rand *expr.ExampleGenerator + values openapi.Values // nameAliases generates named component schemas for primitive alias // types instead of inlining them. Only set when the schemas map feeds // the document components (OpenAPI 3.2 documents): schemafiers whose @@ -51,47 +57,60 @@ type ( } ) -// derived returns a schemafier drawing example values from a stream derived -// from the given identity, sharing all other state. See -// expr.ExampleGenerator.Derived. -func (sf *schemafier) derived(id string) *schemafier { +// at returns a schemafier that draws example values from the sequence selected +// by identity. +func (sf *schemafier) at(identity expr.ExampleIdentity) *schemafier { c := *sf - c.rand = sf.rand.Derived(id) + c.rand = sf.rand.At(identity) return &c } -// rebased returns a schemafier whose example value stream is anchored to the -// given absolute design identity, sharing all other state. See -// expr.ExampleGenerator.Rebased. -func (sf *schemafier) rebased(id string) *schemafier { +// member returns a schemafier that draws examples from the named object's +// field sequence below the current example key. +func (sf *schemafier) member(name string) *schemafier { c := *sf - c.rand = sf.rand.Rebased(id) + c.rand = sf.rand.Member(name) return &c } -// bodyExampleID returns the absolute design identity anchoring the example -// streams of an endpoint request or response body. Anonymous body types -// (inline arrays, maps and primitives) have no type identity of their own so -// their examples anchor on the endpoint that owns them. -func bodyExampleID(svc, endpoint, role string) string { - return svc + "." + endpoint + "." + role +// arrayElement returns a schemafier drawing examples for one array element. +func (sf *schemafier) arrayElement(index int) *schemafier { + c := *sf + c.rand = sf.rand.ArrayElement(index) + return &c } -// fieldOf returns a schemafier whose example value stream is anchored to the -// identity of the named field of the given parent attribute, sharing all -// other state. See expr.ExampleGenerator.Field. -func (sf *schemafier) fieldOf(parent *expr.AttributeExpr, name string) *schemafier { +// mapValue returns a schemafier drawing examples for one map value. +func (sf *schemafier) mapValue(index int) *schemafier { c := *sf - c.rand = sf.rand.Field(parent, name) + c.rand = sf.rand.MapValue(index) + return &c +} + +// unionMember returns a schemafier drawing examples for one union member. +func (sf *schemafier) unionMember(name string) *schemafier { + c := *sf + c.rand = sf.rand.UnionMember(name) + return &c +} + +// field returns a schemafier for a field extracted from parent. Named user +// types use the repeatable key derived from their type; anonymous parents use +// the caller's key. +func (sf *schemafier) field(parent *expr.AttributeExpr, name string, owner expr.ExampleIdentity) *schemafier { + c := *sf + c.rand = sf.rand.At(exampleFieldIdentity(parent, name, owner)) return &c } // newSchemafier initializes a schemafier. -func newSchemafier(rand *expr.ExampleGenerator) *schemafier { +func newSchemafier(rand *expr.ExampleGenerator, values openapi.Values) *schemafier { return &schemafier{ - schemas: make(map[string]*openapi.Schema), - hashes: make(map[uint64][]string), - rand: rand, + schemas: make(map[string]*openapi.Schema), + hashes: make(map[uint64][]string), + preferredNames: make(map[uint64][]string), + rand: rand, + values: values, } } @@ -107,24 +126,25 @@ func newSchemafier(rand *expr.ExampleGenerator) *schemafier { // value indexed by type name. // // NOTE: entries are nil when the corresponding type is Empty. -func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*expr.ResultTypeExpr, ver openapi.Version) (map[string]map[string]*EndpointBodies, map[string]*openapi.Schema) { +func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*expr.ResultTypeExpr, ver openapi.Version, generator *expr.ExampleGenerator, values openapi.Values) (map[string]map[string]*EndpointBodies, map[string]*openapi.Schema) { bodies := make(map[string]map[string]*EndpointBodies) - sf := newSchemafier(api.ExampleGenerator) + sf := newSchemafier(generator, values) sf.nameAliases = ver == openapi.Version32 services := openAPIGeneratedServices(api) + sf.collectPreferredResponseNames(api) // Generates the types referenced from the endpoints. for _, t := range types { if !mustGenerateType(t.Attribute().Meta, services) { continue } - sf.schemafy(&expr.AttributeExpr{Type: t}) + sf.at(expr.UserTypeExampleIdentity(t)).schemafy(&expr.AttributeExpr{Type: t}) } for _, t := range resultTypes { if !mustGenerateType(t.Attribute().Meta, services) { continue } - sf.schemafy(&expr.AttributeExpr{Type: t}) + sf.at(expr.UserTypeExampleIdentity(t)).schemafy(&expr.AttributeExpr{Type: t}) } for _, s := range api.HTTP.Services { @@ -145,9 +165,9 @@ func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*exp reqBody.Description = defaultRequestBodyDescription(e) } } - req := sf.rebased(bodyExampleID(s.Name(), e.Name(), "request")).schemafy(reqBody) + req := sf.at(expr.RequestBodyExampleIdentity(e)).schemafy(reqBody) if e.StreamingBody != nil { - sreq := sf.schemafy(e.StreamingBody) + sreq := sf.at(expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr)).schemafy(e.StreamingBody) var note string if sreq.Ref != "" { note = sreq.Ref @@ -168,13 +188,15 @@ func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*exp } } res := make(map[int][]*openapi.Schema) - resps := e.Responses - for _, er := range e.HTTPErrors { - resps = append(resps, er.Response) + for _, resp := range e.Responses { + identity := expr.ResponseBodyExampleIdentity(e, resp) + js := sf.at(identity).schemafy(staticViewBody(resp)) + res[resp.StatusCode] = append(res[resp.StatusCode], js) } - for i, resp := range resps { - id := bodyExampleID(s.Name(), e.Name(), "response."+strconv.Itoa(resp.StatusCode)+"."+strconv.Itoa(i)) - js := sf.rebased(id).schemafy(staticViewBody(resp)) + for _, httpError := range e.HTTPErrors { + identity := expr.ErrorResponseBodyExampleIdentity(e, httpError) + resp := httpError.Response + js := sf.at(identity).schemafy(staticViewBody(resp)) res[resp.StatusCode] = append(res[resp.StatusCode], js) } eb := &EndpointBodies{RequestBody: req, ResponseBodies: res} @@ -188,48 +210,86 @@ func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*exp return bodies, sf.schemas } +// collectPreferredResponseNames records released component names before any +// equal authored type can claim the same schema. +func (sf *schemafier) collectPreferredResponseNames(api *expr.APIExpr) { + for _, service := range api.HTTP.Services { + for _, endpoint := range service.HTTPEndpoints { + for _, response := range endpoint.Responses { + sf.collectPreferredResponseName(response) + } + for _, transportError := range endpoint.HTTPErrors { + sf.collectPreferredResponseName(transportError.Response) + } + } + } + for _, names := range sf.preferredNames { + slices.Sort(names) + } +} + +// collectPreferredResponseName records each unique released name for one +// response schema shape. Shapes with several names keep their shared name. +func (sf *schemafier) collectPreferredResponseName(response *expr.HTTPResponseExpr) { + _, preferred := responseBodyProjection(response) + for _, userType := range preferred { + if _, explicit := userType.Attribute().Meta["openapi:typename"]; explicit { + continue + } + attribute := &expr.AttributeExpr{Type: userType} + hash := sf.hashAttribute(attribute, fnv.New64()) + name := codegen.Goify(userType.Name(), true) + if !slices.Contains(sf.preferredNames[hash], name) { + sf.preferredNames[hash] = append(sf.preferredNames[hash], name) + } + } +} + // buildSSEItemSchema returns the JSON schema describing a single event // streamed by the given server-sent events endpoint as defined by the OpenAPI // 3.2 sequential media types. The schema is an object whose properties mirror -// the SSE event fields mapped by the design: data is always present, event, -// id and retry only when the design maps them. String and bytes data is -// written raw on the wire while other types are JSON-encoded, which the data -// property reflects using the JSON schema contentMediaType and contentSchema -// keywords. +// the SSE event fields mapped by the design. A selected optional data field may +// be absent; the full streaming result and required fields are always present. +// Primitive values are written as raw text while structured values are JSON, +// which the data property describes with contentMediaType and contentSchema. func (sf *schemafier) buildSSEItemSchema(e *expr.HTTPEndpointExpr) *openapi.Schema { sse := e.SSE sr := e.MethodExpr.StreamingResult data := sr - dsf := sf + owner := expr.MethodStreamingResultExampleIdentity(e.MethodExpr) + dsf := sf.at(owner) if sse.DataField != "" { data = expr.AsObject(sr.Type).Attribute(sse.DataField) - dsf = sf.fieldOf(sr, sse.DataField) + dsf = sf.field(sr, sse.DataField, owner) } var dataSchema *openapi.Schema - switch data.Type { - case expr.String, expr.Bytes: + if expr.IsPrimitive(data.Type) { dataSchema = dsf.schemafy(data) - default: + } else { dataSchema = &openapi.Schema{ Type: openapi.String, ContentMediaType: "application/json", ContentSchema: dsf.schemafy(data), } } + var required []string + if sse.DataField == "" || sr.IsRequired(sse.DataField) { + required = []string{"data"} + } props := map[string]*openapi.Schema{"data": dataSchema} if sse.EventField != "" { - props["event"] = sf.fieldOf(sr, sse.EventField).schemafy(expr.AsObject(sr.Type).Attribute(sse.EventField)) + props["event"] = sf.field(sr, sse.EventField, owner).schemafy(expr.AsObject(sr.Type).Attribute(sse.EventField)) } if sse.IDField != "" { - props["id"] = sf.fieldOf(sr, sse.IDField).schemafy(expr.AsObject(sr.Type).Attribute(sse.IDField)) + props["id"] = sf.field(sr, sse.IDField, owner).schemafy(expr.AsObject(sr.Type).Attribute(sse.IDField)) } if sse.RetryField != "" { - props["retry"] = sf.fieldOf(sr, sse.RetryField).schemafy(expr.AsObject(sr.Type).Attribute(sse.RetryField)) + props["retry"] = sf.field(sr, sse.RetryField, owner).schemafy(expr.AsObject(sr.Type).Attribute(sse.RetryField)) } return &openapi.Schema{ Type: openapi.Object, Properties: props, - Required: []string{"data"}, + Required: required, } } @@ -238,17 +298,28 @@ func (sf *schemafier) buildSSEItemSchema(e *expr.HTTPEndpointExpr) *openapi.Sche // view the result type is projected onto a detached copy of the body: the // design expression tree is read-only for the generators. func staticViewBody(resp *expr.HTTPResponseExpr) *expr.AttributeExpr { - view, ok := resp.Body.Meta.Last(expr.ViewMetaKey) - if !ok || view == "" { - return resp.Body + body, _ := responseBodyProjection(resp) + return body +} + +// responseBodyProjection returns a detached response body and the released +// component names that may describe its schemas. +func responseBodyProjection(resp *expr.HTTPResponseExpr) (*expr.AttributeExpr, []expr.UserType) { + result, ok := resp.Body.Type.(*expr.ResultTypeExpr) + if !ok { + return resp.Body, nil } - body := expr.DupAtt(resp.Body) - rt, err := expr.Project(body.Type.(*expr.ResultTypeExpr), view) - if err != nil { - panic(fmt.Sprintf("failed to project %q to view %q", body.Type.Name(), view)) // bug + view, selected := resp.Body.Meta.Last(expr.ViewMetaKey) + if (!selected || view == "") && expr.AsArray(result.Type) == nil { + return resp.Body, nil } - body.Type = rt - return body + if !selected || view == "" { + view = expr.DefaultView + } + body := expr.DupAtt(resp.Body) + projection := openapi.ProjectResponseResult(result, view) + body.Type = projection.Result + return body, projection.Preferred } func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi.Schema { @@ -297,7 +368,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi } case *expr.Array: s.Type = openapi.Array - s.Items = sf.derived("0").schemafy(t.ElemType) + s.Items = sf.arrayElement(0).schemafy(t.ElemType) case *expr.Object: s.Type = openapi.Object var itemNotes []string @@ -305,7 +376,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi if !openapi.MustGenerate(nat.Attribute.Meta) { continue } - s.Properties[nat.Name] = sf.derived(nat.Name).schemafy(nat.Attribute) + s.Properties[nat.Name] = sf.member(nat.Name).schemafy(nat.Attribute) } if len(itemNotes) > 0 { note = strings.Join(itemNotes, "\n") @@ -317,7 +388,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi // See https://swagger.io/docs/specification/data-models/dictionaries/. s.AdditionalProperties = true } else { - s.AdditionalProperties = sf.derived("val0").schemafy(t.ElemType) + s.AdditionalProperties = sf.mapValue(0).schemafy(t.ElemType) } case *expr.Union: // Each branch owns both its discriminator literal and value schema so @@ -334,14 +405,14 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi Type: openapi.String, Enum: []any{val.Name}, }, - valueKey: sf.derived(val.Name).schemafy(val.Attribute), + valueKey: sf.unionMember(val.Name).schemafy(val.Attribute), }, Required: []string{typeKey, valueKey}, }) } case expr.UserType: if expr.IsAlias(t) && !sf.nameAliases { - s = sf.rebased(t.ID()).schemafy(t.Attribute()) + s = sf.at(expr.UserTypeExampleIdentity(t)).schemafy(t.Attribute()) break } h := sf.hashAttribute(attr, fnv.New64()) @@ -369,6 +440,8 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi name := t.Name() if metaName != "" { name = metaName + } else if preferred := sf.preferredNames[h]; len(preferred) == 1 { + name = preferred[0] } else if n, ok := t.Attribute().Meta["name:original"]; ok { name = n[0] } @@ -376,17 +449,17 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi typeName := sf.uniquify(codegen.Goify(name, true)) s.Ref = toRef(typeName) sf.hashes[h] = append(sf.hashes[h], s.Ref) - schema := sf.rebased(t.ID()).schemafy(t.Attribute(), true) + schema := sf.at(expr.UserTypeExampleIdentity(t)).schemafy(t.Attribute(), true) if schema.Description == "" { - schema.Description = userTypeDescription(t, attr) + schema.Description = sf.userTypeDescription(t, attr) } sf.schemas[typeName] = schema return s // All other schema properties are set in the reference default: panic(fmt.Sprintf("unknown type %T", t)) // bug } - if attr.Description != "" { - s.Description = attr.Description + if description := sf.values.Description(attr.AuthoredAttribute(), attr.Description); description != "" { + s.Description = description } if note != "" { s.Description += "\n" + note @@ -394,7 +467,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi // Default value, example, extensions s.DefaultValue = toStringMap(attr.DefaultValue) - s.Example = openapi.Example(attr, sf.rand) + s.Example = openapi.ProjectExample(attr, sf.values.Example(attr, sf.rand)) s.Extensions = openapi.ExtensionsFromExpr(attr.Meta) // Validations @@ -448,10 +521,8 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi return s } -// ensureSchemaDescription updates an existing component schema with the type or -// reference attribute description if the component was first created without -// one. This preserves user type descriptions when structurally equivalent types -// are reused under a component reference. +// ensureSchemaDescription gives an existing component the description owned by +// its Goa type when the component was first created without one. func (sf *schemafier) ensureSchemaDescription(ref string, t expr.UserType, attr *expr.AttributeExpr) { const prefix = "#/components/schemas/" typeName := strings.TrimPrefix(ref, prefix) @@ -462,16 +533,19 @@ func (sf *schemafier) ensureSchemaDescription(ref string, t expr.UserType, attr if schema == nil || schema.Description != "" { return } - schema.Description = userTypeDescription(t, attr) + schema.Description = sf.userTypeDescription(t, attr) } -// userTypeDescription returns the canonical description for a user type schema, -// falling back to the description of the attribute that introduced the type. -func userTypeDescription(t expr.UserType, attr *expr.AttributeExpr) string { - if desc := t.Attribute().Description; desc != "" { - return desc +// userTypeDescription returns text owned by the Goa type. A generated type may +// use text from its surrounding attribute only when both came from the same +// expression in the design. +func (sf *schemafier) userTypeDescription(t expr.UserType, attr *expr.AttributeExpr) string { + typeAtt := t.Attribute() + description := typeAtt.Description + if description == "" && typeAtt.AuthoredAttribute() == attr.AuthoredAttribute() { + description = attr.Description } - return attr.Description + return sf.values.Description(typeAtt.AuthoredAttribute(), description) } // uniquify returns n if n is not a known type name. Otherwise uniquify appends diff --git a/http/codegen/openapi/v3/types_test.go b/http/codegen/openapi/v3/types_test.go index 6b23e592c2..239a840db0 100644 --- a/http/codegen/openapi/v3/types_test.go +++ b/http/codegen/openapi/v3/types_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI v3 schema construction and stable example +// generation for primitive, collection, object, and transport body types. package openapiv3 import ( @@ -216,7 +218,7 @@ func TestBuildBodyTypes(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) svc, ok := bodies[svcName] if !ok { @@ -411,7 +413,7 @@ func TestMapTypes(t *testing.T) { t.Run(tc.Name, func(t *testing.T) { // Build the OpenAPI spec root := codegen.RunDSL(t, tc.DSL) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) // Find the service and method svcBodies, ok := bodies[svcName] @@ -500,7 +502,7 @@ func validateAdditionalPropsSchema(t *testing.T, ctx string, schema *openapi.Sch func TestTypesOnlyDifferByEnum(t *testing.T) { root := codegen.RunDSL(t, dsls.StringEnumBodyDSL()) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) svc1, ok := bodies["svc_enum_1"] if !ok { @@ -549,7 +551,7 @@ func TestBuildBodyTypesPreservesPrimitiveAliasComponents(t *testing.T) { }) }) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version32) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version32, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) tests := []struct { name string status int @@ -700,7 +702,7 @@ func TestHashAttribute(t *testing.T) { } h := fnv.New64() - sf := newSchemafier(expr.NewRandom("test")) + sf := newSchemafier(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")), openapi.Values{}) for _, group := range cases { t.Run(group.name, func(t *testing.T) { diff --git a/http/codegen/openapi/v3/types_union_test.go b/http/codegen/openapi/v3/types_union_test.go index c0a87ee798..f072544997 100644 --- a/http/codegen/openapi/v3/types_union_test.go +++ b/http/codegen/openapi/v3/types_union_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI 3 schema conversion for unions, including that a +// typed owner keeps each discriminator paired with its generated member value. package openapiv3 import ( @@ -11,7 +13,11 @@ import ( ) func TestSchemafyCorrelatesUnionDiscriminatorAndValue(t *testing.T) { - schema := (&schemafier{rand: expr.NewRandom("test")}).schemafy(unionAttribute()) + method := &expr.MethodExpr{Name: "union", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(method), + ) + schema := (&schemafier{rand: generator}).schemafy(unionAttribute()) require.Len(t, schema.AnyOf, 2) assertUnionSchemaBranch(t, schema.AnyOf[0], "text", openapi.Type(openapi.String)) diff --git a/http/codegen/openapi/values.go b/http/codegen/openapi/values.go new file mode 100644 index 0000000000..37485458e8 --- /dev/null +++ b/http/codegen/openapi/values.go @@ -0,0 +1,139 @@ +// This file stores alternate OpenAPI text and examples for one specification +// build. Builders read these values without changing the evaluated Goa design. +package openapi + +import ( + "maps" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // storedExample keeps an immutable example value with the exact design + // expression used to find its translated description. + storedExample struct { + value *expr.ExampleExpr + source *expr.ExampleExpr + } + + // Values contains alternate titles, descriptions, and examples for one + // OpenAPI build. The zero value uses the evaluated Goa design unchanged. + // Methods that add values return a new independent Values. + Values struct { + titles map[eval.Expression]string + descriptions map[eval.Expression]string + examples map[*expr.AttributeExpr][]storedExample + } +) + +// WithTitle returns a copy of v that uses title for target. +func (v Values) WithTitle(target eval.Expression, title string) Values { + result := v.copy() + if result.titles == nil { + result.titles = make(map[eval.Expression]string) + } + result.titles[target] = title + return result +} + +// WithDescription returns a copy of v that uses description for target. +func (v Values) WithDescription(target eval.Expression, description string) Values { + result := v.copy() + if result.descriptions == nil { + result.descriptions = make(map[eval.Expression]string) + } + result.descriptions[target] = description + return result +} + +// WithExamples returns a copy of v that uses examples for attribute. Copies +// made from attribute by Goa use the same examples. +func (v Values) WithExamples(attribute *expr.AttributeExpr, examples []*expr.ExampleExpr) Values { + result := v.copy() + if result.examples == nil { + result.examples = make(map[*expr.AttributeExpr][]storedExample) + } + result.examples[attribute.AuthoredAttribute()] = storeExamples(examples) + return result +} + +// Title returns the title stored for target or fallback when none was stored. +func (v Values) Title(target eval.Expression, fallback string) string { + if title, ok := v.titles[target]; ok { + return title + } + return fallback +} + +// Description returns the description stored for target or fallback when none +// was stored. +func (v Values) Description(target eval.Expression, fallback string) string { + if description, ok := v.descriptions[target]; ok { + return description + } + return fallback +} + +// Examples returns the examples stored for attribute or a copy of fallback +// when none were stored. +func (v Values) Examples(attribute *expr.AttributeExpr, fallback []*expr.ExampleExpr) []*expr.ExampleExpr { + if examples, ok := v.examples[attribute.AuthoredAttribute()]; ok { + return v.materializeExamples(examples) + } + if userType, ok := attribute.Type.(expr.UserType); ok { + if examples, ok := v.examples[userType.Attribute().AuthoredAttribute()]; ok { + return v.materializeExamples(examples) + } + } + return v.materializeExamples(storeExamples(fallback)) +} + +// Example returns the last stored or authored example, or generates one when +// none exists. A generator configured to suppress examples returns nil. +func (v Values) Example(attribute *expr.AttributeExpr, generator *expr.ExampleGenerator) any { + copy := *attribute + copy.UserExamples = v.Examples(attribute, attribute.ExtractUserExamples()) + return copy.Example(generator) +} + +// copy returns independent maps while retaining the immutable values they +// contain. Example lists are copied again when they are changed or read. +func (v Values) copy() Values { + return Values{ + titles: maps.Clone(v.titles), + descriptions: maps.Clone(v.descriptions), + examples: maps.Clone(v.examples), + } +} + +// storeExamples copies a complete example list while retaining the exact +// expression used to look up each translated description. +func storeExamples(examples []*expr.ExampleExpr) []storedExample { + if examples == nil { + return nil + } + result := make([]storedExample, len(examples)) + for index, example := range examples { + copy := *example + copy.Value = duplicateJSONValue(example.Value) + result[index] = storedExample{value: ©, source: example} + } + return result +} + +// materializeExamples returns a fresh example list with every replacement +// description applied from its exact source expression. +func (v Values) materializeExamples(examples []storedExample) []*expr.ExampleExpr { + if examples == nil { + return nil + } + result := make([]*expr.ExampleExpr, len(examples)) + for index, stored := range examples { + copy := *stored.value + copy.Description = v.Description(stored.source, copy.Description) + copy.Value = duplicateJSONValue(stored.value.Value) + result[index] = © + } + return result +} diff --git a/http/codegen/openapi/values_test.go b/http/codegen/openapi/values_test.go new file mode 100644 index 0000000000..af10e5cb3d --- /dev/null +++ b/http/codegen/openapi/values_test.go @@ -0,0 +1,86 @@ +// This file verifies that OpenAPI text and examples can be replaced for one +// build without changing the evaluated Goa design. +package openapi + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +func TestValues(t *testing.T) { + api := expr.NewAPIExpr("calc", nil) + attribute := &expr.AttributeExpr{Type: expr.String} + examples := []*expr.ExampleExpr{{Summary: "default", Value: "translated"}} + + var empty Values + require.Equal(t, "original title", empty.Title(api, "original title")) + require.Equal(t, "original description", empty.Description(api, "original description")) + require.Equal(t, []*expr.ExampleExpr(nil), empty.Examples(attribute, nil)) + + localized := empty. + WithTitle(api, "localized title"). + WithDescription(api, "localized description"). + WithExamples(attribute, examples) + + require.Equal(t, "localized title", localized.Title(api, "original title")) + require.Equal(t, "localized description", localized.Description(api, "original description")) + require.Equal(t, examples, localized.Examples(attribute, nil)) + require.Equal(t, "original title", empty.Title(api, "original title")) + + // Values owns its example list so neither caller nor reader can change it. + examples[0] = &expr.ExampleExpr{Summary: "changed", Value: "changed"} + firstRead := localized.Examples(attribute, nil) + require.Equal(t, "translated", firstRead[0].Value) + firstRead[0] = &expr.ExampleExpr{Summary: "changed again", Value: "changed again"} + require.Equal(t, "translated", localized.Examples(attribute, nil)[0].Value) +} + +func TestValuesUseAuthoredAttributeForCopies(t *testing.T) { + authored := &expr.AttributeExpr{Type: expr.String} + copy := expr.DupAtt(expr.DupAtt(authored)) + localized := (Values{}).WithExamples(authored, []*expr.ExampleExpr{{Value: "translated"}}) + + require.Equal(t, "translated", localized.Examples(copy, nil)[0].Value) +} + +func TestValuesOwnCompleteExampleLists(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + examples := []*expr.ExampleExpr{ + {Summary: "first", Value: map[string]any{"items": []any{"one"}}}, + {Summary: "second", Value: "two"}, + } + values := (Values{}).WithExamples(attribute, examples) + + examples[0].Value.(map[string]any)["items"].([]any)[0] = "changed" + firstRead := values.Examples(attribute, nil) + require.Len(t, firstRead, 2) + require.Equal(t, "one", firstRead[0].Value.(map[string]any)["items"].([]any)[0]) + require.Equal(t, "two", firstRead[1].Value) + + firstRead[0].Value.(map[string]any)["items"].([]any)[0] = "changed again" + require.Equal(t, "one", values.Examples(attribute, nil)[0].Value.(map[string]any)["items"].([]any)[0]) +} + +func TestValuesApplyExampleDescriptionsRegardlessOfCallOrder(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + example := &expr.ExampleExpr{Summary: "default", Description: "original", Value: "value"} + + values := (Values{}). + WithExamples(attribute, []*expr.ExampleExpr{example}). + WithDescription(example, "translated") + + require.Equal(t, "translated", values.Examples(attribute, nil)[0].Description) + require.Equal(t, "original", example.Description) +} + +func TestDocsFromExprWithValues(t *testing.T) { + docs := &expr.DocsExpr{Description: "original", URL: "https://goa.design"} + values := (Values{}).WithDescription(docs, "localized") + + localized := DocsFromExprWithValues(docs, nil, values) + require.Equal(t, "localized", localized.Description) + require.Equal(t, "original", DocsFromExpr(docs, nil).Description) +} diff --git a/http/codegen/openapi_disabled_examples_test.go b/http/codegen/openapi_disabled_examples_test.go new file mode 100644 index 0000000000..c8d3c9e0e4 --- /dev/null +++ b/http/codegen/openapi_disabled_examples_test.go @@ -0,0 +1,59 @@ +// This file verifies that disabling OpenAPI examples uses document-private +// disabled generators and never changes service or evaluated design state. +package codegen + +import ( + "bytes" + "maps" + "strings" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestOpenAPIDisabledExamplesDoNotConsumeServiceState(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + root.API.Meta = expr.MetaExpr{"openapi:example": {"false"}} + factory := root.API.RandomizerFactory + meta := maps.Clone(root.API.Meta) + examples := expr.NewExampleGenerator(factory) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, examples) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() + method := services.Get("testService").Methods[0] + payloadExample := method.PayloadEx + require.NotNil(t, payloadExample) + + plan, err := NewOpenAPIPlan(root, examples) + require.NoError(t, err) + files := plan.Files() + require.Len(t, files, 6) + for _, file := range files { + require.Len(t, file.SectionTemplates, 1) + section := file.SectionTemplates[0] + var rendered bytes.Buffer + tmpl := template.Must(template.New("openapi").Funcs(section.FuncMap).Parse(section.Source)) + require.NoError(t, tmpl.Execute(&rendered, section.Data)) + content := rendered.String() + if strings.HasSuffix(file.Path, ".json") { + require.NotContains(t, content, `"example"`) + } else { + require.NotContains(t, content, "\nexample:") + } + } + + require.Equal(t, payloadExample, method.PayloadEx) + require.Equal(t, factory, root.API.RandomizerFactory) + require.Equal(t, meta, root.API.Meta) +} diff --git a/http/codegen/openapi_order_independence_test.go b/http/codegen/openapi_order_independence_test.go index 88431e87a9..84a7bce3cf 100644 --- a/http/codegen/openapi_order_independence_test.go +++ b/http/codegen/openapi_order_independence_test.go @@ -1,3 +1,5 @@ +// This file verifies that HTTP and OpenAPI generation produce the same examples +// regardless of which one reads the design first. package codegen import ( @@ -9,53 +11,42 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/expr" - "goa.design/goa/v3/http/codegen/openapi" "goa.design/goa/v3/http/codegen/testdata" ) -// TestOpenAPIOrderIndependence verifies that the OpenAPI specifications do not -// depend on whether the HTTP transport data was computed first: the HTTP -// analyze pass must treat the design expression tree as read-only so the -// OpenAPI generators always see the pristine design. The production "goa gen" -// flow runs the transport generators before the OpenAPI one while the OpenAPI -// golden tests run on pristine roots; any difference between the two -// generations is output that production emits but no golden test covers. +// TestOpenAPIOrderIndependence verifies that building HTTP files first does not +// change the OpenAPI documents. HTTP generation must not change the design that +// the OpenAPI generator reads afterward. func TestOpenAPIOrderIndependence(t *testing.T) { cases := []struct { Name string DSL func() }{ - // Aliased payload/result attributes: makeHTTPType used to flatten - // the aliases in place which changed the schemas OpenAPI generated. + // Named request and result fields used to be flattened in place, which + // changed the OpenAPI schemas generated afterward. {"alias-type", testdata.AliasTypeDSL}, {"result-body-multiple-views", testdata.ResultBodyMultipleViewsDSL}, {"explicit-view", testdata.ExplicitViewDSL}, {"error-response", testdata.PrimitiveErrorResponseDSL}, {"streaming-result", testdata.StreamingResultDSL}, {"streaming-payload", testdata.StreamingPayloadDSL}, - // NOTE: methods declaring anonymous object results (e.g. - // testdata.SSEObjectDSL) only pass this check because the raw - // object wrapping moved out of the service analyze pass into - // codegen.NormalizeRoot which CreateHTTPServices applies before - // computing the transport data. The pristine root below is rendered - // without normalization, so designs whose OpenAPI output depends on - // the wrapping must normalize both roots (see - // TestGeneratorsTreatDesignAsReadOnly in codegen/generator for the - // full read-only guarantee). + // Anonymous object results pass because codegen.NewGeneration prepares + // those result objects before HTTP generation starts. See + // TestGeneratorsTreatDesignAsReadOnly for the check that covers the whole + // generation run. {"sse", testdata.SSEStringDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Golden test order: generate the OpenAPI specifications from a - // pristine root. + // First build OpenAPI documents from an untouched design. pristine := renderOpenAPI(t, expr.RunDSL(t, c.DSL)) - // Production order: compute the HTTP transport data first, then - // generate the OpenAPI specifications from the same root. + // Then build HTTP files first and OpenAPI documents second from the + // same design. root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) for _, svc := range root.API.HTTP.Services { - require.NotNil(t, services.Get(svc.Name())) + require.NotNil(t, plan.services.Get(svc.Name())) } produced := renderOpenAPI(t, root) @@ -68,15 +59,13 @@ func TestOpenAPIOrderIndependence(t *testing.T) { } // renderOpenAPI generates and renders all the OpenAPI specification files for -// the given root and returns their content indexed by file path. The global -// schema registry and the example generator are reset first so that two -// generations of identical design trees yield identical documents. +// the given root and returns their content indexed by file path. The call uses +// a fresh example generator so two identical designs yield identical documents. func renderOpenAPI(t *testing.T, root *expr.RootExpr) map[string]string { t.Helper() - openapi.Definitions = make(map[string]*openapi.Schema) - root.API.ExampleGenerator = expr.NewRandom(root.API.Name) - files, err := OpenAPIFiles(root) + plan, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) + files := plan.Files() out := make(map[string]string, len(files)) for _, f := range files { require.Len(t, f.SectionTemplates, 1) diff --git a/http/codegen/openapi_plan_test.go b/http/codegen/openapi_plan_test.go new file mode 100644 index 0000000000..3749f79dbc --- /dev/null +++ b/http/codegen/openapi_plan_test.go @@ -0,0 +1,143 @@ +// This file checks that an OpenAPI plan keeps the documents it built. +package codegen + +import ( + "bytes" + "path/filepath" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestOpenAPIPlanKeepsBuiltFiles(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + root.API.Contact = &expr.ContactExpr{Name: "before"} + root.API.License = &expr.LicenseExpr{Name: "before"} + root.API.HTTP.Consumes = []string{"application/before"} + root.API.HTTP.Produces = []string{"application/before"} + plan, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + files := plan.Files() + before := renderOpenAPIFiles(t, files) + + root.API.Title = "changed after planning" + root.API.HTTP.Services = nil + root.API.Contact.Name = "after" + root.API.License.Name = "after" + root.API.HTTP.Consumes[0] = "application/after" + root.API.HTTP.Produces[0] = "application/after" + + filesAgain := plan.Files() + require.Len(t, filesAgain, len(files)) + for i := range files { + require.Same(t, files[i], filesAgain[i]) + } + require.Equal(t, before, renderOpenAPIFiles(t, filesAgain)) +} + +func TestOpenAPIPlanWithValuesDoesNotChangeDesign(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + values := (openapi.Values{}).WithTitle(root.API, "Localized API") + + plan, err := NewOpenAPIPlanWithValues( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + require.NoError(t, err) + rendered := renderOpenAPIFiles(t, plan.Files()) + for _, document := range rendered { + require.Contains(t, document, "Localized API") + } + require.NotEqual(t, "Localized API", root.API.Title) +} + +func TestNewOpenAPIPlanFromSpecsUsesExactVersionsAndPaths(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + plan, err := NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + []openapi.Spec{ + {Version: openapi.Version20, Path: "docs/api.v2"}, + {Version: openapi.Version32, Path: "reference/api"}, + }, + openapi.Values{}, + ) + require.NoError(t, err) + paths := make([]string, len(plan.Files())) + for index, file := range plan.Files() { + paths[index] = file.Path + } + require.Equal(t, []string{ + filepath.Join("gen", "docs", "api.v2.json"), + filepath.Join("gen", "docs", "api.v2.yaml"), + filepath.Join("gen", "reference", "api.json"), + filepath.Join("gen", "reference", "api.yaml"), + }, paths) +} + +func TestNewOpenAPIPlanFromSpecsRejectsInvalidSpecs(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + tests := []struct { + name string + specs []openapi.Spec + err string + }{ + {name: "unknown version", specs: []openapi.Spec{{Version: "4.0", Path: "http/api"}}, err: `unsupported OpenAPI version "4.0"`}, + {name: "empty path", specs: []openapi.Spec{{Version: openapi.Version30}}, err: "path cannot be empty"}, + {name: "absolute path", specs: []openapi.Spec{{Version: openapi.Version30, Path: "/api"}}, err: "path must be relative"}, + {name: "escaping path", specs: []openapi.Spec{{Version: openapi.Version30, Path: "../api"}}, err: "path must not escape"}, + {name: "json extension", specs: []openapi.Spec{{Version: openapi.Version30, Path: "api.json"}}, err: "path must not include an extension"}, + {name: "same version", specs: []openapi.Spec{{Version: openapi.Version30, Path: "api"}, {Version: openapi.Version30, Path: "other"}}, err: `version "3.0" appears more than once`}, + {name: "same path", specs: []openapi.Spec{{Version: openapi.Version20, Path: "api"}, {Version: openapi.Version30, Path: "api"}}, err: `same output path "api"`}, + {name: "portable path collision", specs: []openapi.Spec{{Version: openapi.Version20, Path: "API"}, {Version: openapi.Version30, Path: "api"}}, err: "case-insensitive filesystem"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + test.specs, + openapi.Values{}, + ) + require.ErrorContains(t, err, test.err) + }) + } +} + +func TestNewOpenAPIPlanWrappersKeepOrdinaryOutput(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + specs, err := openapi.Specs(root.API.Meta) + require.NoError(t, err) + ordinary, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + explicit, err := NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + specs, + openapi.Values{}, + ) + require.NoError(t, err) + require.Equal(t, renderOpenAPIFiles(t, ordinary.Files()), renderOpenAPIFiles(t, explicit.Files())) +} + +// renderOpenAPIFiles renders each planned file and returns its text by path. +func renderOpenAPIFiles(t *testing.T, files []*goacodegen.File) map[string]string { + t.Helper() + rendered := make(map[string]string, len(files)) + for _, file := range files { + require.Len(t, file.SectionTemplates, 1) + section := file.SectionTemplates[0] + var output bytes.Buffer + tmpl := template.Must(template.New(section.Name).Funcs(section.FuncMap).Parse(section.Source)) + require.NoError(t, tmpl.Execute(&output, section.Data)) + rendered[file.Path] = output.String() + } + return rendered +} diff --git a/http/codegen/openapi_test.go b/http/codegen/openapi_test.go index 66228ae900..5d4f211f72 100644 --- a/http/codegen/openapi_test.go +++ b/http/codegen/openapi_test.go @@ -1,3 +1,5 @@ +// This file verifies HTTP OpenAPI generation uses prepared service data and +// run-owned example streams without mutating the evaluated design. package codegen import ( @@ -8,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/expr" - openapi "goa.design/goa/v3/http/codegen/openapi" "goa.design/goa/v3/http/codegen/testdata" ) @@ -21,12 +22,10 @@ func TestOpenAPI(t *testing.T) { "valid": {DSL: testdata.SimpleDSL, NilSpec: false}, } for k, c := range cases { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - spec, err := OpenAPIFiles(root) + plan, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) - assert.Equal(t, c.NilSpec, spec == nil, k) + assert.Equal(t, c.NilSpec, len(plan.Files()) == 0, k) } } @@ -72,15 +71,14 @@ func TestOutputPath(t *testing.T) { }} for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - o, err := OpenAPIFiles(root) + plan, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) if c.Err != "" { require.EqualError(t, err, c.Err) return } require.NoError(t, err) + o := plan.Files() require.Len(t, o, len(c.Paths)) for i, p := range c.Paths { assert.Equal(t, p, o[i].Path) diff --git a/http/codegen/paths.go b/http/codegen/paths.go index ef537ccac0..627bab8bbc 100644 --- a/http/codegen/paths.go +++ b/http/codegen/paths.go @@ -8,8 +8,8 @@ import ( "goa.design/goa/v3/expr" ) -// PathFiles returns the service path files. -func PathFiles(data *ServicesData) []*codegen.File { +// pathFiles builds the service path files read by Plan.Link. +func pathFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, 2*len(data.Expressions.Services)) for i := 0; i < len(data.Expressions.Services); i++ { fw[i*2] = serverPath(data.Expressions.Services[i], data) @@ -49,10 +49,17 @@ func pathSections(svc *expr.HTTPServiceExpr, pkg string, services *ServicesData) ) sdata := services.Get(svc.Name()) for _, e := range svc.HTTPEndpoints { + data := struct { + *EndpointData + Client bool + }{ + EndpointData: sdata.Endpoint(e.Name()), + Client: pkg == "client", + } sections = append(sections, &codegen.SectionTemplate{ Name: "path", Source: httpTemplates.Read(pathT), - Data: sdata.Endpoint(e.Name()), + Data: data, }) } diff --git a/http/codegen/paths_test.go b/http/codegen/paths_test.go index 00c17dcf6f..77eefb1cc6 100644 --- a/http/codegen/paths_test.go +++ b/http/codegen/paths_test.go @@ -38,8 +38,8 @@ func TestPaths(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) require.Len(t, root.API.HTTP.Services, 1) - services := CreateHTTPServices(root) - fs := serverPath(root.API.HTTP.Services[0], services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.PathFiles()[0] sections := fs.SectionTemplates code := codegen.SectionCode(t, sections[1]) testutil.AssertGo(t, "testdata/golden/paths_"+c.Name+".go.golden", code) @@ -64,8 +64,8 @@ func TestPathTrailingShash(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) require.Len(t, root.API.HTTP.Services, 1) - services := CreateHTTPServices(root) - fs := serverPath(root.API.HTTP.Services[0], services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.PathFiles()[0] sections := fs.SectionTemplates code := codegen.SectionCode(t, sections[1]) testutil.AssertGo(t, "testdata/golden/paths_"+c.Name+".go.golden", code) diff --git a/http/codegen/plan.go b/http/codegen/plan.go new file mode 100644 index 0000000000..5d995f89ed --- /dev/null +++ b/http/codegen/plan.go @@ -0,0 +1,2037 @@ +// This file builds HTTP output in two steps. NewPlans requests every Go package +// name that the output files need. Plan.Link then builds the HTTP and JSON-RPC +// data after the service names are known. +package codegen + +import ( + "cmp" + "fmt" + "net/http" + "path" + "slices" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // PlanInput pairs one design with the generated service names chosen for it. + PlanInput struct { + // Root contains the HTTP services that Goa will generate. + Root *expr.RootExpr + // Service is the service plan created for Root. + Service *service.Plan + } + + // Plan records the generated Go declarations for one design and later builds + // its HTTP files. + Plan struct { + root *expr.RootExpr + servicePlan *service.Plan + generation *codegen.Generation + transport transportKind + serverPackages map[*expr.HTTPServiceExpr]*codegen.GeneratedPackage + extensions map[*expr.HTTPServiceExpr]*serverExtensions + constructors map[viewedConstructorKey]*codegen.NameDeclaration + payloads map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + streams map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + errors map[*expr.HTTPErrorExpr]*codegen.NameDeclaration + wireTypes map[*expr.HTTPServiceExpr]*plannedWireTypes + symbols map[*expr.HTTPServiceExpr]*httpSymbols + servicePaths map[*expr.HTTPServiceExpr]string + cliParsers map[string]*cli.ParserPlan + fileImports map[string]*plannedFileImports + services *ServicesData + viewed map[viewedMethodKey]*viewedResultPlan + jsonServices map[string]*jsonRPCServicePlan + server []*codegen.File + client []*codegen.File + serverTypes []*codegen.File + clientTypes []*codegen.File + paths []*codegen.File + clientCLI []*codegen.File + } + + // ServerMountPoint describes one route added by a declared server mount. + // Goa includes it in Server.Mounts so logs and startup output list the added + // route with the routes defined in the design. + ServerMountPoint struct { + // Method is the operation name shown for the route. + Method string + // Verb is the HTTP method accepted by the route. + Verb string + // Pattern is the path pattern accepted by the route. + Pattern string + } + + // ServerMount gives server templates the chosen mount function name and + // the routes that function adds. + ServerMount struct { + // Declaration supplies the generated mount function name. + Declaration *codegen.NameDeclaration + // MountPoints lists the routes added by Declaration. + MountPoints []ServerMountPoint + } + + // ExamplePlan builds runnable HTTP programs from server data and generated + // services that came from the same design. + ExamplePlan struct { + root *example.Root + transport *Plan + } + + // jsonRPCServicePlan stores the HTTP data copied for the JSON-RPC file writer. + jsonRPCServicePlan struct { + data *ServiceData + fileImports map[string][]*codegen.ImportSpec + clientCodec *codegen.File + serverCodec *codegen.File + clientServiceImport *codegen.ImportSpec + serverServiceImport *codegen.ImportSpec + clientViewImport *codegen.ImportSpec + serverViewImport *codegen.ImportSpec + } + + // viewedResultPlan stores the HTTP response data copied for the JSON-RPC file + // writer. + viewedResultPlan struct { + variable bool + fixedView string + service *service.ViewedResultTypeData + representations []viewedRepresentationPlan + } + + // viewedRepresentationPlan associates one body conversion with the headers + // and cookies written by the same successful response. + viewedRepresentationPlan struct { + data *ViewedRepresentationData + headers []*HeaderData + cookies []*CookieData + } + + // serverExtensions stores declarations submitted for one HTTP service before + // Generation.Freeze chooses their Go names. Link copies these values into + // template data. + serverExtensions struct { + handlerWrappers []*codegen.NameDeclaration + endpointHandlerWrappers map[*expr.HTTPEndpointExpr][]*codegen.NameDeclaration + mounts []*ServerMount + } + + // JSONRPCServiceSnapshot holds a separate copy of the HTTP service data used to write + // JSON-RPC client and server files. Callers may change it without changing + // the HTTP plan or a later copy. + JSONRPCServiceSnapshot struct { + // Service is a copy of the generated Goa service description. + Service JSONRPCServiceData + // Endpoints contains the JSON-RPC method data in design order. + Endpoints []JSONRPCEndpointSnapshot + // ClientStruct is the client type name kept for existing plugins. Goa + // copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning. + ClientStruct string + // ClientStructDeclaration supplies the client type name written in HTTP files. + ClientStructDeclaration *codegen.NameDeclaration + // ClientInitDeclaration supplies the client constructor name. + ClientInitDeclaration *codegen.NameDeclaration + // ServerStruct is the server type name kept for existing plugins. Goa + // copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ServerStructDeclaration.Name() after planning. + ServerStruct string + // ServerStructDeclaration supplies the server type name written in HTTP files. + ServerStructDeclaration *codegen.NameDeclaration + // ServerInit is the server constructor name kept for existing plugins. Goa + // copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ServerInitDeclaration.Name() after planning. + ServerInit string + // ServerInitDeclaration supplies the server constructor name written in HTTP files. + ServerInitDeclaration *codegen.NameDeclaration + // MountServer is the route mount function name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use MountServerDeclaration.Name() after planning. + MountServer string + // MountServerDeclaration supplies the route mounting function name written in HTTP files. + MountServerDeclaration *codegen.NameDeclaration + // ServerService is the generated function that returns the service implementation. + ServerService string + clientServiceImport *codegen.ImportSpec + serverServiceImport *codegen.ImportSpec + clientViewImport *codegen.ImportSpec + serverViewImport *codegen.ImportSpec + fileImports map[string][]*codegen.ImportSpec + clientCodec *codegen.File + serverCodec *codegen.File + } + + // JSONRPCServiceData contains the service names written in JSON-RPC files. + JSONRPCServiceData struct { + // Name is the design service name. + Name string + // StructName is the exported Go spelling derived from Name. + StructName string + // EndpointsDeclaration supplies the service endpoint collection name. + EndpointsDeclaration *codegen.NameDeclaration + // MethodNamesDeclaration supplies the service method name list. + MethodNamesDeclaration *codegen.NameDeclaration + // PkgName is the import name of the generated service package. + PkgName string + // PathName is the generated service directory name. + PathName string + } + + // JSONRPCEndpointSnapshot holds a separate copy of the HTTP values that + // JSON-RPC files read for one service method. + JSONRPCEndpointSnapshot struct { + // IsJSONRPC is true because this value describes a JSON-RPC method. + IsJSONRPC bool + // Method contains the service method names and stream methods used in JSON-RPC files. + Method JSONRPCMethodData + // ServiceName is the design service name written in generated errors. + ServiceName string + // ServicePkgName is the import name used for service types. + ServicePkgName string + // Payload describes the JSON-RPC request. It is nil when the method has no payload. + Payload *JSONRPCPayloadData + // Result describes the JSON-RPC result. It is nil when the method has no result. + Result *JSONRPCResultData + // Errors lists the designed errors returned by the method. + Errors []JSONRPCErrorGroupData + // Routes lists the HTTP paths and verbs accepted by the JSON-RPC server. + Routes []JSONRPCRouteData + // RequestInit builds the HTTP request used for a JSON-RPC call. + RequestInit *InitData + // EndpointInit is the client method that builds the Goa endpoint. + EndpointInit string + // HandlerInit is the handler constructor name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use HandlerInitDeclaration.Name() after planning. + HandlerInit string + // HandlerInitDeclaration supplies the server handler constructor name. + HandlerInitDeclaration *codegen.NameDeclaration + // ClientStruct is the client type name kept for existing plugins. Goa + // copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning. + ClientStruct string + // ClientStructDeclaration supplies the client type name used by request builders. + ClientStructDeclaration *codegen.NameDeclaration + // RequestEncoder is the request encoder name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename + // generated code. It is empty when Goa does not generate a request encoder. + // + // Deprecated: Use RequestEncoderDeclaration.Name() after planning. + RequestEncoder string + // RequestEncoderDeclaration supplies the request encoder name written in HTTP files. + RequestEncoderDeclaration *codegen.NameDeclaration + // RequestDecoder is the request decoder name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename + // generated code. It is empty when Goa does not generate a request decoder. + // + // Deprecated: Use RequestDecoderDeclaration.Name() after planning. + RequestDecoder string + // RequestDecoderDeclaration supplies the request decoder name written in HTTP files. + RequestDecoderDeclaration *codegen.NameDeclaration + // ResponseDecoder is the response decoder name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ResponseDecoderDeclaration.Name() after planning. + ResponseDecoder string + // ResponseDecoderDeclaration supplies the response decoder name written in HTTP files. + ResponseDecoderDeclaration *codegen.NameDeclaration + // SSE contains event-stream values when the method uses server-sent events. + SSE *JSONRPCSSEData + } + + // JSONRPCMethodData contains the service method values written in JSON-RPC files. + JSONRPCMethodData struct { + // Name is the design method name. + Name string + // VarName is the exported Go method name. + VarName string + // Result is the generated service result type name. + Result string + // HasMixedResults reports whether the method returns one synchronous type + // and streams another type. + HasMixedResults bool + // Idempotent reports whether the client may retry the same call. + Idempotent bool + // Errors lists the retry properties of the method errors. + Errors []JSONRPCMethodErrorData + // ViewedResult contains result-view names when the method returns a viewed result. + ViewedResult *JSONRPCMethodViewedResultData + // ServerStream contains server stream method names when the method streams. + ServerStream *JSONRPCStreamData + // ClientStream contains client stream method names when the method streams. + ClientStream *JSONRPCStreamData + // StreamKind identifies which side sends stream values. + StreamKind expr.StreamKind + // SkipRequestBodyEncodeDecode reports whether the service reads the raw request body. + SkipRequestBodyEncodeDecode bool + // RequestStruct is the service type that carries a raw request body. + RequestStruct string + } + + // JSONRPCMethodErrorData contains the two error values used by client retry code. + JSONRPCMethodErrorData struct { + // ErrName is the service error name. + ErrName string + // Temporary reports whether retrying the call may succeed. + Temporary bool + } + + // JSONRPCMethodViewedResultData contains the result-view fields written in method files. + JSONRPCMethodViewedResultData struct { + JSONRPCViewedResultData + // ViewName is the fixed view name. It is empty when each response selects a view. + ViewName string + } + + // JSONRPCStreamData contains the stream method names written by JSON-RPC files. + JSONRPCStreamData struct { + // Interface is the service stream interface implemented by the generated stream. + Interface string + // VarName is the generated stream implementation type name. + VarName string + // SendName is the method that sends one value. + SendName string + // SendDesc documents SendName. + SendDesc string + // SendWithContextName is the send method that accepts a context. + SendWithContextName string + // SendWithContextDesc documents SendWithContextName. + SendWithContextDesc string + // SendTypeName is the sent service type name. + SendTypeName string + // SendTypeRef is the sent service type reference. + SendTypeRef string + // RecvName is the method that receives one value. + RecvName string + // RecvDesc documents RecvName. + RecvDesc string + // RecvWithContextName is the receive method that accepts a context. + RecvWithContextName string + // RecvWithContextDesc documents RecvWithContextName. + RecvWithContextDesc string + // RecvTypeName is the received service type name. + RecvTypeName string + // RecvTypeRef is the received service type reference. + RecvTypeRef string + // EndpointStruct is the service type passed to a streaming endpoint. + EndpointStruct string + // Kind identifies which side sends stream values. + Kind expr.StreamKind + } + + // JSONRPCPayloadData contains the request values read by JSON-RPC files. + JSONRPCPayloadData struct { + // Ref is the service payload type reference. + Ref string + // Request describes the request body when the payload has one. + Request *JSONRPCRequestData + // IDAttribute is the payload field that receives the JSON-RPC request ID. + IDAttribute string + // IDAttributeRequired reports whether IDAttribute is a value instead of a pointer. + IDAttributeRequired bool + // DecoderReturnValue is returned directly when the server needs no payload constructor. + DecoderReturnValue string + } + + // JSONRPCRequestData contains the request body values read by JSON-RPC files. + JSONRPCRequestData struct { + // ClientBody describes the request body encoded by the client. + ClientBody *JSONRPCBodyData + // ServerBody describes the request body decoded by the server. + ServerBody *JSONRPCBodyData + // PayloadInit builds the service payload from decoded request values. + PayloadInit *InitData + // Headers contains the HTTP request headers read by shared JSON code. + Headers []JSONRPCHeaderData + // Cookies contains the HTTP request cookies read by shared JSON code. + Cookies []JSONRPCCookieData + // QueryParams is empty because JSON-RPC parameters are carried in the JSON request. + QueryParams []any + // PathParams is empty because every JSON-RPC method uses the service route. + PathParams []any + // PayloadAttr is the payload field encoded as the JSON request body. + PayloadAttr string + // MustHaveBody reports whether an empty JSON request is invalid. + MustHaveBody bool + // MustValidate reports whether decoded request values require validation. + MustValidate bool + } + + // JSONRPCResultData contains the response values read by JSON-RPC files. + JSONRPCResultData struct { + // Ref is the service result type reference. + Ref string + // Responses contains the successful HTTP responses in design order. + Responses []JSONRPCResponseData + // IDAttribute is the result field that supplies the JSON-RPC response ID. + IDAttribute string + // IDAttributeRequired reports whether IDAttribute is a value instead of a pointer. + IDAttributeRequired bool + // View is the default result view selected by the design. + View string + } + + // JSONRPCResponseData contains one HTTP response read by JSON-RPC files. + JSONRPCResponseData struct { + // StatusCode is the JSON-RPC code used for a designed error. + StatusCode string + // Code is the numeric JSON-RPC code used for a designed error. + Code int + // Headers contains fields decoded from HTTP response headers. + Headers []JSONRPCHeaderData + // Cookies contains fields decoded from HTTP response cookies. + Cookies []JSONRPCCookieData + // ServerBody contains the response bodies written by the server. + ServerBody []JSONRPCBodyData + // ClientBody describes the response body read by the client. + ClientBody *JSONRPCBodyData + // ResultInit builds the service result or error from decoded response values. + ResultInit *InitData + // MustValidate reports whether decoded header or cookie values require validation. + MustValidate bool + } + + // JSONRPCErrorGroupData contains errors that use the same JSON-RPC code. + JSONRPCErrorGroupData struct { + // StatusCode is the JSON-RPC code shared by Errors. + StatusCode string + // Errors contains the designed errors for StatusCode. + Errors []JSONRPCErrorData + } + + // JSONRPCErrorData contains one designed error and its response conversion. + JSONRPCErrorData struct { + // Name is the design error name. + Name string + // Ref is the generated service error type reference. + Ref string + // Response describes the encoded error data. + Response JSONRPCResponseData + } + + // JSONRPCRouteData contains one HTTP path and verb used for JSON-RPC calls. + JSONRPCRouteData struct { + // Verb is the uppercase HTTP method. + Verb string + // Path is the full request path. + Path string + } + + // JSONRPCSSEData contains the event fields read by JSON-RPC stream files. + JSONRPCSSEData struct { + // StructDeclaration supplies the server stream type name. + StructDeclaration *codegen.NameDeclaration + // ClientInterfaceDeclaration supplies the client stream interface name. + ClientInterfaceDeclaration *codegen.NameDeclaration + // ClientStructDeclaration supplies the client stream implementation name. + ClientStructDeclaration *codegen.NameDeclaration + // ClientInitDeclaration supplies the client stream constructor name. + ClientInitDeclaration *codegen.NameDeclaration + // EventTypeRef is the service result type carried by each event. + EventTypeRef string + // HasResponseBody reports whether Response converts the service result to JSON. + HasResponseBody bool + // Response is the successful response used to encode stream events. + Response *JSONRPCResponseData + // RequestIDField is the payload field that receives Last-Event-ID. + RequestIDField string + // RequestIDPointer reports whether RequestIDField stores a pointer. + RequestIDPointer bool + } + + // JSONRPCBodyData contains only the JSON body fields read by JSON-RPC files. + JSONRPCBodyData struct { + // Declaration supplies the generated body type name. It is nil when the + // body uses a Go type expression that does not declare a named type. + Declaration *codegen.NameDeclaration + // VarName is the generated body type name. + VarName string + // Ref is the generated body type reference. + Ref string + // ValidateRef is inline validation code run after decoding. + ValidateRef string + // ValidatorDeclaration supplies the named validator called after decoding. + ValidatorDeclaration *codegen.NameDeclaration + // ValidationTarget is the decoded value passed to ValidatorDeclaration. It + // is empty when this body does not need a named validator call. + ValidationTarget string + // Init converts between the body and the service value. + Init *InitData + } + + // JSONRPCElementData contains one header or cookie value read from a response. + JSONRPCElementData struct { + // Name is the service attribute name used in errors. + Name string + // VarName is the local Go variable name. + VarName string + // TypeName is the Goa primitive or array name. + TypeName string + // ElemTypeName is the Goa name of an array element. It is empty for non-arrays. + ElemTypeName string + // ElemTypeRef is the generated Go reference for an array element. It is empty for non-arrays. + ElemTypeRef string + // TypeRef is the generated Go type reference. + TypeRef string + // Pointer reports whether TypeRef is a pointer. + Pointer bool + // FieldName is the service result field that supplies the value on the server. + FieldName string + // FieldPointer reports whether FieldName holds a pointer. + FieldPointer bool + // IsAliased reports whether FieldName uses a user-defined primitive type. + IsAliased bool + // Required reports whether the response must contain the value. + Required bool + // DefaultValue is written when the response omits an optional value. + DefaultValue any + // Validate contains the validation code run after conversion. + Validate string + // HTTPName is the header or cookie name sent over HTTP. + HTTPName string + // StringSlice reports whether the value is an array of strings. + StringSlice bool + // Slice reports whether the value is an array. + Slice bool + } + + // JSONRPCHeaderData contains one response header read by JSON-RPC clients. + JSONRPCHeaderData struct { + JSONRPCElementData + // CanonicalName is the standard HTTP spelling, such as "Content-Type". + CanonicalName string + } + + // JSONRPCCookieData contains one response cookie read by JSON-RPC clients. + JSONRPCCookieData struct { + JSONRPCElementData + // MaxAge is the cookie max-age text written to generated code. + MaxAge string + // Path is the cookie path written to generated code. + Path string + // Domain is the cookie domain written to generated code. + Domain string + // Secure reports whether the Secure cookie flag is set. + Secure bool + // HTTPOnly reports whether the HttpOnly cookie flag is set. + HTTPOnly bool + // SameSite is the cookie SameSite text written to generated code. + SameSite string + } + + // ViewedResultSnapshot holds a separate copy of the result views and HTTP response bodies + // used by one JSON-RPC method. + ViewedResultSnapshot struct { + // Variable reports whether each response carries its selected view. + Variable bool + // FixedView is the view selected in the generated method when it cannot vary. + FixedView string + // Service contains the viewed-result names written in JSON-RPC files. + Service JSONRPCViewedResultData + // Representations contains one copied response conversion for each legal view. + Representations []ViewedRepresentationSnapshot + } + + // JSONRPCViewedResultData contains the service package names and functions + // needed to validate and convert one viewed result. + JSONRPCViewedResultData struct { + // FullRef is the complete Go reference to the viewed-result type. + FullRef string + // VarName is the viewed-result type name without its package. + VarName string + // ViewsPkg is the import name of the generated views package. + ViewsPkg string + // Validate is the function that validates the viewed result. + Validate *codegen.NameDeclaration + // ResultInit converts a viewed result into the service result. + ResultInit *codegen.NameDeclaration + // Init converts the service result into a viewed result. + Init *codegen.NameDeclaration + // IsCollection reports whether the viewed result is a collection. + IsCollection bool + } + + // ViewedRepresentationSnapshot holds copied client and server body data + // for one legal result view. + ViewedRepresentationSnapshot struct { + // View is the result view carried by the response. + View string + // ResultAttr is the Go field selected by Body("name"). It is empty when + // the server converts the complete projected result. + ResultAttr string + // ServerBody describes the value encoded by the server. It is nil when a + // successful response carries only headers or cookies. + ServerBody *JSONRPCBodyData + // ClientBody describes the value decoded by the client. It is nil when a + // successful response carries only headers or cookies. + ClientBody *JSONRPCBodyData + // ResultInit describes how the decoded body rebuilds the service result. + ResultInit InitData + // Headers contains copied response header mappings. + Headers []JSONRPCHeaderData + // Cookies contains copied response cookie mappings. + Cookies []JSONRPCCookieData + } + + // viewedMethodKey identifies one service method without joining its names. + viewedMethodKey struct { + service string + method string + } + + // viewedConstructorKey identifies one view-specific client result function + // in the input design. + viewedConstructorKey struct { + endpoint *expr.HTTPEndpointExpr + response *expr.HTTPResponseExpr + view string + } + + // viewedConstructorOrder provides a stable total order for colliding + // constructor preferences in one generated client package. + viewedConstructorOrder struct { + transport string + api string + service string + method string + status int + tagName string + tagValue string + view string + role string + } + + // plannedWireTypes stores each copied request and response field with the + // client or server package that defines it. Plan.Link uses the same copies + // after Generation.Freeze chooses every generated Go name. + plannedWireTypes struct { + bodies shapedBodies + server *wireTypeCatalog + client *wireTypeCatalog + transforms plannedWireTransforms + streamPayloads map[*expr.HTTPEndpointExpr]*wireTypeRecord + clientBodyConstructors map[clientBodyConstructorKey]*codegen.NameDeclaration + clientBodyConstructorNames map[clientBodyConstructorKey]string + } + + // plannedFileImports stores the package that writes one generated file and + // every design-derived package path referenced by that file. + plannedFileImports struct { + output *codegen.GeneratedPackage + paths []string + } + + // plannedWireTransforms retains the exact conversion selected while HTTP + // request and response shapes are collected. + plannedWireTransforms struct { + requests map[clientBodyConstructorKey]*plannedRequestTransforms + responses map[viewedConstructorKey]*plannedResponseTransforms + errors map[*expr.HTTPErrorExpr]*plannedResponseTransforms + streamingResults map[*expr.HTTPEndpointExpr]*plannedResponseTransforms + } + + // plannedRequestTransforms contains each direction used by request body and + // streaming payload code. + plannedRequestTransforms struct { + clientEncode wireTransformHandle + serverDecode wireTransformHandle + clientDecode wireTransformHandle + } + + // plannedResponseTransforms contains the server encoder and client decoder + // for one response representation. + plannedResponseTransforms struct { + serverEncode wireTransformHandle + clientDecode wireTransformHandle + clientDecodeDirect bool + } + + // clientBodyConstructorKey identifies an unnamed request body constructor. + clientBodyConstructorKey struct { + endpoint *expr.HTTPEndpointExpr + role wireTypeRole + } + + // transportKind records whether a plan writes HTTP or JSON-RPC files. + transportKind uint8 +) + +const ( + httpTransport transportKind = iota + 1 + jsonrpcTransport +) + +// NewPlans submits the Go names used by every ordinary HTTP design in inputs. +// All inputs are required so two designs that write the same package resolve +// name conflicts together. +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + return newPlans(generation, httpTransport, inputs) +} + +// NewJSONRPCPlans requests the HTTP body, encoder, and decoder names used by +// every JSON-RPC design in inputs. JSON-RPC writes its files from these plans. +func NewJSONRPCPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + return newPlans(generation, jsonrpcTransport, inputs) +} + +// NewExamplePlan returns an example renderer only when examples contains the +// server data copied from transport's service design. +func NewExamplePlan(transport *Plan, examples *example.Plan) (*ExamplePlan, error) { + root, ok := examples.Root(transport.servicePlan) + if !ok { + return nil, fmt.Errorf("HTTP examples require server data created from the same service design") + } + return &ExamplePlan{root: root, transport: transport}, nil +} + +// MatchesHTTP reports whether NewPlans created p for root and servicePlan. +func (p *Plan) MatchesHTTP(root *expr.RootExpr, servicePlan *service.Plan) bool { + return p.transport == httpTransport && p.root == root && p.servicePlan == servicePlan +} + +// MatchesJSONRPC reports whether NewJSONRPCPlans created p for root and +// servicePlan. +func (p *Plan) MatchesJSONRPC(root *expr.RootExpr, servicePlan *service.Plan) bool { + return p.transport == jsonrpcTransport && p.root == root && p.servicePlan == servicePlan +} + +// DeclareServerHandlerWrapper records an exported func(http.Handler) +// http.Handler that wraps each designed endpoint handler, file handler, and +// redirect mounted for service. Wrappers are applied in registration order, +// with the first registered function surrounding the others. Routes added by +// DeclareServerMount are not wrapped automatically. +func (p *Plan) DeclareServerHandlerWrapper(service *expr.HTTPServiceExpr, preferred string, order codegen.PackageNameOrder) (*codegen.NameDeclaration, error) { + pkg, err := p.serverExtensionPackage(service) + if err != nil { + return nil, err + } + declaration := codegen.NewPreferredName(codegen.NameFunction, preferred, codegen.ExportedName, order) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + p.extensions[service].handlerWrappers = append(p.extensions[service].handlerWrappers, declaration) + return declaration, nil +} + +// DeclareServerEndpointHandlerWrapper records an unexported func(http.Handler) +// http.Handler that wraps the designed routes for endpoint. Service wrappers +// surround endpoint wrappers. File handlers and routes added by plugins are not +// affected. +func (p *Plan) DeclareServerEndpointHandlerWrapper(endpoint *expr.HTTPEndpointExpr, preferred string, order codegen.PackageNameOrder) (*codegen.NameDeclaration, error) { + pkg, service, err := p.serverEndpointExtensionPackage(endpoint) + if err != nil { + return nil, err + } + declaration := codegen.NewPreferredName(codegen.NameFunction, preferred, codegen.UnexportedName, order) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + extensions := p.extensions[service] + extensions.endpointHandlerWrappers[endpoint] = append(extensions.endpointHandlerWrappers[endpoint], declaration) + return declaration, nil +} + +// DeclareServerMount records an exported func(goahttp.Muxer) that adds routes +// to the HTTP server mux. Goa calls the function after mounting routes from the +// design and includes mountPoints in the server's route list. +func (p *Plan) DeclareServerMount(service *expr.HTTPServiceExpr, preferred string, order codegen.PackageNameOrder, mountPoints []ServerMountPoint) (*codegen.NameDeclaration, error) { + pkg, err := p.serverExtensionPackage(service) + if err != nil { + return nil, err + } + if len(mountPoints) == 0 { + return nil, fmt.Errorf("HTTP server mount requires at least one mount point") + } + for index, mount := range mountPoints { + switch { + case mount.Method == "": + return nil, fmt.Errorf("HTTP server mount point %d has an empty method", index) + case mount.Verb == "": + return nil, fmt.Errorf("HTTP server mount point %d has an empty verb", index) + case mount.Pattern == "": + return nil, fmt.Errorf("HTTP server mount point %d has an empty pattern", index) + } + } + declaration := codegen.NewPreferredName(codegen.NameFunction, preferred, codegen.ExportedName, order) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + p.extensions[service].mounts = append(p.extensions[service].mounts, &ServerMount{ + Declaration: declaration, + MountPoints: append([]ServerMountPoint(nil), mountPoints...), + }) + return declaration, nil +} + +// Link reads the chosen Go declaration and import names, builds template data +// for each HTTP service once, and builds every file returned by this plan. +func (p *Plan) Link() error { + if !p.generation.Frozen() { + return fmt.Errorf("HTTP plan cannot link before generation freeze") + } + if p.services != nil { + return fmt.Errorf("HTTP plan is already linked") + } + if err := p.link(); err != nil { + return err + } + return nil +} + +// ServerFiles returns the HTTP server files built by Link. +func (p *Plan) ServerFiles() []*codegen.File { + p.requireLinked() + return p.server +} + +// Service returns the template data built by Link for the supplied HTTP service +// expression. Callers must call Link before reading the service data. +func (p *Plan) Service(service *expr.HTTPServiceExpr) (*ServiceData, bool) { + p.requireLinked() + if _, ok := p.extensions[service]; !ok { + return nil, false + } + data := p.services.Get(service.Name()) + return data, data != nil +} + +// ClientFiles returns the HTTP client files built by Link. +func (p *Plan) ClientFiles() []*codegen.File { + p.requireLinked() + return p.client +} + +// ServerTypeFiles returns the server request and response type files built by Link. +func (p *Plan) ServerTypeFiles() []*codegen.File { + p.requireLinked() + return p.serverTypes +} + +// ClientTypeFiles returns the client request and response type files built by Link. +func (p *Plan) ClientTypeFiles() []*codegen.File { + p.requireLinked() + return p.clientTypes +} + +// PathFiles returns the URL path helper files built by Link. +func (p *Plan) PathFiles() []*codegen.File { + p.requireLinked() + return p.paths +} + +// ClientCLIFiles returns the command-line client files built by Link. +func (p *Plan) ClientCLIFiles() []*codegen.File { + p.requireLinked() + return p.clientCLI +} + +// ServerFiles builds runnable HTTP servers from the copied server data. +func (p *ExamplePlan) ServerFiles() []*codegen.File { + p.transport.requireLinked() + return exampleServerFiles(p.root, p.transport.services) +} + +// CLIFiles builds runnable HTTP clients from the copied server data. +func (p *ExamplePlan) CLIFiles() []*codegen.File { + p.transport.requireLinked() + return exampleCLIFiles(p.root, p.transport.services) +} + +// CombinedServerFiles returns new runnable server files containing this plan's +// JSON-RPC services and application's ordinary HTTP services. Pass nil when +// the design has no ordinary HTTP services. +func (p *ExamplePlan) CombinedServerFiles(application *Plan) []*codegen.File { + p.transport.requireLinked() + if p.transport.transport != jsonrpcTransport { + panic("combined example servers require a JSON-RPC HTTP plan") + } + var applicationServices *ServicesData + if application != nil { + application.requireLinked() + if application.transport != httpTransport || application.root != p.transport.root || application.servicePlan != p.transport.servicePlan { + panic("ordinary HTTP and JSON-RPC plans must use the same design root and service plan") + } + applicationServices = application.services + } + return combinedExampleServerFiles(p.root, p.transport.services, applicationServices) +} + +// ViewedResult returns copied HTTP response data for the named method's result +// views. The second result is false when the method does not use result views. +func (p *Plan) ViewedResult(serviceName, methodName string) (ViewedResultSnapshot, bool) { + p.requireLinked() + viewed, ok := p.viewed[viewedMethodKey{service: serviceName, method: methodName}] + if !ok { + return ViewedResultSnapshot{}, false + } + representations := make([]ViewedRepresentationSnapshot, len(viewed.representations)) + for index, planned := range viewed.representations { + representation := planned.data + if representation.ResultInit == nil { + panic("viewed result representation is missing its result constructor") + } + representations[index] = ViewedRepresentationSnapshot{ + View: representation.View, + ResultAttr: representation.ResultAttr, + ServerBody: copyJSONRPCBody(representation.ServerBody), + ClientBody: copyJSONRPCBody(representation.ClientBody), + ResultInit: *copyInitData(representation.ResultInit), + Headers: copyJSONRPCHeaders(planned.headers), + Cookies: copyJSONRPCCookies(planned.cookies), + } + } + return ViewedResultSnapshot{ + Variable: viewed.variable, + FixedView: viewed.fixedView, + Service: copyJSONRPCViewedResult(viewed.service), + Representations: representations, + }, true +} + +// JSONRPCService returns copied HTTP information used to write one JSON-RPC +// service. The second result is false when the plan has no service with name. +func (p *Plan) JSONRPCService(name string) (JSONRPCServiceSnapshot, bool) { + p.requireLinked() + planned, ok := p.jsonServices[name] + if !ok { + return JSONRPCServiceSnapshot{}, false + } + endpoints := make([]JSONRPCEndpointSnapshot, len(planned.data.Endpoints)) + for index, endpoint := range planned.data.Endpoints { + endpoints[index] = copyJSONRPCEndpoint(endpoint) + } + fileImports := make(map[string][]*codegen.ImportSpec, len(planned.fileImports)) + for filePath, imports := range planned.fileImports { + fileImports[filePath] = cloneImportSpecs(imports) + } + return JSONRPCServiceSnapshot{ + Service: JSONRPCServiceData{ + Name: planned.data.Service.Name, + StructName: planned.data.Service.StructName, + EndpointsDeclaration: planned.data.Service.EndpointsDeclaration, + MethodNamesDeclaration: planned.data.Service.MethodNamesDeclaration, + PkgName: planned.data.Service.PkgName, + PathName: planned.data.Service.PathName, + }, + Endpoints: endpoints, + ClientStruct: planned.data.ClientStructDeclaration.Name(), + ClientStructDeclaration: planned.data.ClientStructDeclaration, + ClientInitDeclaration: planned.data.ClientInitDeclaration, + ServerStruct: planned.data.ServerStructDeclaration.Name(), + ServerStructDeclaration: planned.data.ServerStructDeclaration, + ServerInit: planned.data.ServerInitDeclaration.Name(), + ServerInitDeclaration: planned.data.ServerInitDeclaration, + MountServer: planned.data.MountServerDeclaration.Name(), + MountServerDeclaration: planned.data.MountServerDeclaration, + ServerService: planned.data.ServerService, + clientServiceImport: cloneImportSpec(planned.clientServiceImport), + serverServiceImport: cloneImportSpec(planned.serverServiceImport), + clientViewImport: cloneImportSpec(planned.clientViewImport), + serverViewImport: cloneImportSpec(planned.serverViewImport), + fileImports: fileImports, + clientCodec: planned.clientCodec, + serverCodec: planned.serverCodec, + }, true +} + +// ClientServiceImport returns the service import used by the JSON-RPC client package. +func (p JSONRPCServiceSnapshot) ClientServiceImport() *codegen.ImportSpec { + return cloneImportSpec(p.clientServiceImport) +} + +// ServerServiceImport returns the service import used by the JSON-RPC server package. +func (p JSONRPCServiceSnapshot) ServerServiceImport() *codegen.ImportSpec { + return cloneImportSpec(p.serverServiceImport) +} + +// ClientViewImport returns the result-view import used by the JSON-RPC client package. +func (p JSONRPCServiceSnapshot) ClientViewImport() *codegen.ImportSpec { + if p.clientViewImport == nil { + panic("JSON-RPC service does not use result views") + } + return cloneImportSpec(p.clientViewImport) +} + +// ServerViewImport returns the result-view import used by the JSON-RPC server package. +func (p JSONRPCServiceSnapshot) ServerViewImport() *codegen.ImportSpec { + if p.serverViewImport == nil { + panic("JSON-RPC service does not use result views") + } + return cloneImportSpec(p.serverViewImport) +} + +// FileImports returns a new copy of the service-type imports needed by one +// JSON-RPC output file. It rejects paths that this service does not generate. +func (p JSONRPCServiceSnapshot) FileImports(filePath string) []*codegen.ImportSpec { + imports, ok := p.fileImports[strings.ReplaceAll(filePath, "\\", "/")] + if !ok { + panic("JSON-RPC file is not part of this HTTP service plan") + } + return cloneImportSpecs(imports) +} + +// ClientCodecFile returns a new client encoder and decoder file for this +// service. The JSON-RPC file writer may change the returned file. It returns +// nil when the service needs neither function. +func (p JSONRPCServiceSnapshot) ClientCodecFile() *codegen.File { + return cloneJSONRPCCodecFile(p.clientCodec) +} + +// ServerCodecFile returns a new server encoder and decoder file for this +// service. The JSON-RPC file writer may change the returned file. It returns +// nil when the service needs neither function. +func (p JSONRPCServiceSnapshot) ServerCodecFile() *codegen.File { + return cloneJSONRPCCodecFile(p.serverCodec) +} + +// serverExtensionPackage returns the generated server package that will contain +// service's extension functions. It first checks that this ordinary HTTP plan +// still accepts new declarations. +func (p *Plan) serverExtensionPackage(service *expr.HTTPServiceExpr) (*codegen.GeneratedPackage, error) { + if err := p.validateServerExtensionLifecycle(); err != nil { + return nil, err + } + if service == nil { + return nil, fmt.Errorf("HTTP server extension requires a service from this plan") + } + pkg, ok := p.serverPackages[service] + if !ok { + return nil, fmt.Errorf("HTTP service does not belong to this plan") + } + return pkg, nil +} + +// serverEndpointExtensionPackage returns the generated server package and +// service that contain endpoint. It first checks that this ordinary HTTP plan +// still accepts new declarations. +func (p *Plan) serverEndpointExtensionPackage(endpoint *expr.HTTPEndpointExpr) (*codegen.GeneratedPackage, *expr.HTTPServiceExpr, error) { + if err := p.validateServerExtensionLifecycle(); err != nil { + return nil, nil, err + } + if endpoint == nil { + return nil, nil, fmt.Errorf("HTTP server endpoint wrapper requires an endpoint from this plan") + } + for service, symbols := range p.symbols { + if _, ok := symbols.endpoints[endpoint]; ok { + return p.serverPackages[service], service, nil + } + } + return nil, nil, fmt.Errorf("HTTP endpoint does not belong to this plan") +} + +// validateServerExtensionLifecycle checks that the plan still accepts new Go +// declarations and that it writes ordinary HTTP files. +func (p *Plan) validateServerExtensionLifecycle() error { + if p.transport != httpTransport { + return fmt.Errorf("JSON-RPC HTTP plans do not support server extensions") + } + if p.services != nil { + return fmt.Errorf("HTTP server extension cannot be declared after plan linking") + } + if p.generation.Frozen() { + return fmt.Errorf("HTTP server extension cannot be declared after generation freeze") + } + return nil +} + +// planImports records each import on the generated package that writes the +// reference. NewPlans calls it after those packages have been claimed. +func planImports(generation *codegen.Generation, transport transportKind, plans []*Plan) error { + for _, candidate := range generation.Roots() { + design, ok := candidate.(*expr.RootExpr) + if !ok { + continue + } + expressions := transportExpressions(design, transport) + if len(expressions.Services) == 0 { + continue + } + plan := planForRoot(plans, design) + if plan == nil { + return fmt.Errorf("%s design has no HTTP plan", transportLabel(transport)) + } + dir := transportDirectory(transport) + for _, transportService := range expressions.Services { + pathName := plan.servicePaths[transportService] + clientPath := path.Join(generation.GenPkg(), dir, pathName, "client") + serverPath := path.Join(generation.GenPkg(), dir, pathName, "server") + servicePackage, viewsPackage, err := servicePackagePreferences(plan.servicePlan, transportService) + if err != nil { + return err + } + for index, outputPackage := range []*codegen.GeneratedPackage{ + generation.Package(clientPath), + generation.Package(serverPath), + } { + if err := requireHTTPTransportImports(outputPackage, transportService, index == 0); err != nil { + return err + } + if err := outputPackage.ReserveGeneratedImport(servicePackage); err != nil { + return err + } + if viewsPackage != nil { + if err := outputPackage.ReserveGeneratedImport(viewsPackage); err != nil { + return err + } + } + allPaths, err := planHTTPAttributeImports(generation, outputPackage, serviceReferenceAttributes(transportService.HTTPEndpoints...)...) + if err != nil { + return err + } + side := "server" + if index == 0 { + side = "client" + } + retainPlannedFileImports(plan, outputPackage, allPaths, + path.Join(codegen.Gendir, dir, pathName, side, side+".go"), + path.Join(codegen.Gendir, dir, pathName, side, "encode_decode.go"), + path.Join(codegen.Gendir, dir, pathName, side, "types.go"), + ) + if index == 0 { + retainPlannedFileImports(plan, outputPackage, allPaths, path.Join(codegen.Gendir, dir, pathName, side, "cli.go")) + } + webSocketPaths, err := planHTTPAttributeImports(generation, outputPackage, serviceReferenceAttributes(httpWebSocketEndpoints(transportService)...)...) + if err != nil { + return err + } + retainPlannedFileImports(plan, outputPackage, webSocketPaths, path.Join(codegen.Gendir, dir, pathName, side, "websocket.go")) + ssePaths, err := planHTTPAttributeImports(generation, outputPackage, serviceReferenceAttributes(httpSSEEndpoints(transportService)...)...) + if err != nil { + return err + } + sseFile := "sse.go" + if transport == jsonrpcTransport && index == 0 { + sseFile = "stream.go" + } + retainPlannedFileImports(plan, outputPackage, ssePaths, path.Join(codegen.Gendir, dir, pathName, side, sseFile)) + } + } + if transport == httpTransport { + var rootOutput *codegen.GeneratedPackage + for _, transportService := range expressions.Services { + if !serviceHasMultipartRequest(transportService) { + continue + } + rootPath := path.Dir(generation.GenPkg()) + if rootOutput == nil { + var err error + rootOutput, err = generation.ClaimOutputPackage(rootPath, ".") + if err != nil { + return err + } + } + if err := rootOutput.RequireImport(codegen.SimpleImport("mime/multipart")); err != nil { + return err + } + servicePackage, _, err := servicePackagePreferences(plan.servicePlan, transportService) + if err != nil { + return err + } + servicePath := plan.servicePaths[transportService] + if err := rootOutput.ReserveGeneratedImport(codegen.NewImport( + servicePackage.Name+"svr", + path.Join(generation.GenPkg(), "http", servicePath, "server"), + )); err != nil { + return err + } + var multipartEndpoints []*expr.HTTPEndpointExpr + for _, endpoint := range transportService.HTTPEndpoints { + if endpoint.MultipartRequest { + multipartEndpoints = append(multipartEndpoints, endpoint) + } + } + importPaths, err := planHTTPAttributeImports(generation, rootOutput, serviceReferenceAttributes(multipartEndpoints...)...) + if err != nil { + return err + } + retainPlannedFileImports(plan, rootOutput, importPaths, "multipart.go") + } + } + for _, server := range design.API.Servers { + serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) + cliPath := path.Join(generation.GenPkg(), dir, "cli", serverName) + cliPackage := generation.Package(cliPath) + if err := requireHTTPCLIImports(cliPackage); err != nil { + return err + } + for _, serviceName := range server.Services { + transportService := expressions.Service(serviceName) + if transportService == nil { + continue + } + pathName := plan.servicePaths[transportService] + servicePackage, _, err := servicePackagePreferences(plan.servicePlan, transportService) + if err != nil { + return err + } + if err := cliPackage.ReserveGeneratedImport(codegen.NewImport( + servicePackage.Name+"c", + path.Join(generation.GenPkg(), dir, pathName, "client"), + )); err != nil { + return err + } + if len(transportService.ServiceExpr.ClientInterceptors) > 0 { + servicePackage, _, err := plan.servicePlan.ServicePackageImports(transportService.ServiceExpr) + if err != nil { + return err + } + if err := cliPackage.ReserveGeneratedImport(servicePackage); err != nil { + return err + } + } + } + + rootPath := path.Dir(generation.GenPkg()) + serverOutput, err := generation.ClaimOutputPackage( + path.Join(rootPath, "cmd", serverName), + path.Join("cmd", serverName), + ) + if err != nil { + return err + } + for _, serviceName := range server.Services { + transportService := expressions.Service(serviceName) + if transportService == nil { + continue + } + pathName := plan.servicePaths[transportService] + servicePackage, _, err := servicePackagePreferences(plan.servicePlan, transportService) + if err != nil { + return err + } + preferred := servicePackage.Name + "svr" + if transport == jsonrpcTransport { + preferred = servicePackage.Name + "jssvr" + } + if err := serverOutput.ReserveGeneratedImport(codegen.NewImport( + preferred, + path.Join(generation.GenPkg(), dir, pathName, "server"), + )); err != nil { + return err + } + } + clientOutput, err := generation.ClaimOutputPackage( + path.Join(rootPath, "cmd", serverName+"-cli"), + path.Join("cmd", serverName+"-cli"), + ) + if err != nil { + return err + } + if err := clientOutput.ReserveGeneratedImport(codegen.NewImport("cli", cliPath)); err != nil { + return err + } + for _, service := range design.Services { + servicePackage, _, err := plan.servicePlan.ServicePackageImports(service) + if err != nil { + return err + } + if err := clientOutput.ReserveGeneratedImport(servicePackage); err != nil { + return err + } + } + if err := clientOutput.ReserveGeneratedImport(codegen.NewImport(examplePackageImportName(design), rootPath)); err != nil { + return err + } + for _, service := range design.Services { + if len(service.ClientInterceptors) == 0 { + continue + } + if err := clientOutput.ReserveGeneratedImport(codegen.NewImport("interceptors", rootPath+"/interceptors")); err != nil { + return err + } + break + } + } + } + return nil +} + +// planForRoot returns the transport plan that owns one evaluated design. +func planForRoot(plans []*Plan, root *expr.RootExpr) *Plan { + for _, plan := range plans { + if plan.root == root { + return plan + } + } + return nil +} + +// servicePackagePreferences returns the service package and optional views +// package recorded for every transport method. All methods in one service must +// agree because their generated files share imports. +func servicePackagePreferences(plan *service.Plan, transportService *expr.HTTPServiceExpr) (*codegen.ImportSpec, *codegen.ImportSpec, error) { + servicePackage, availableViewsPackage, err := plan.ServicePackageImports(transportService.ServiceExpr) + if err != nil { + return nil, nil, err + } + var viewsPackage *codegen.ImportSpec + for _, endpoint := range transportService.HTTPEndpoints { + methodService, methodViews, err := plan.MethodPackageImports(endpoint.MethodExpr) + if err != nil { + return nil, nil, err + } + if *servicePackage != *methodService { + return nil, nil, fmt.Errorf("HTTP service %q methods use different generated service packages", transportService.Name()) + } + if methodViews == nil { + continue + } + if *availableViewsPackage != *methodViews { + return nil, nil, fmt.Errorf("HTTP service %q methods use different generated views packages", transportService.Name()) + } + viewsPackage = availableViewsPackage + } + return servicePackage, viewsPackage, nil +} + +// retainPlannedFileImports records each package path referenced by the named +// generated files. Repeated calls merge paths when several services contribute +// declarations to one output file such as multipart.go. +func retainPlannedFileImports(plan *Plan, output *codegen.GeneratedPackage, importPaths []string, filePaths ...string) { + for _, filePath := range filePaths { + key := filepathKey(filePath) + retained := plan.fileImports[key] + if retained == nil { + retained = &plannedFileImports{output: output} + plan.fileImports[key] = retained + } + seen := make(map[string]struct{}, len(retained.paths)) + for _, importPath := range retained.paths { + seen[importPath] = struct{}{} + } + for _, importPath := range importPaths { + if _, ok := seen[importPath]; ok { + continue + } + seen[importPath] = struct{}{} + retained.paths = append(retained.paths, importPath) + } + slices.Sort(retained.paths) + } +} + +// examplePackageImportName returns the package name imported by runnable +// examples for the starter service implementations at the module root. +func examplePackageImportName(root *expr.RootExpr) string { + scope := codegen.NewNameScope() + for _, service := range root.Services { + scope.Unique(strings.ToLower(codegen.Goify(service.Name, false))) + } + return scope.Unique(strings.ToLower(codegen.Goify(root.API.Name, false)), "api") +} + +// requireHTTPTransportImports records the fixed package names used by one +// service's generated client or server files. +func requireHTTPTransportImports(outputPackage *codegen.GeneratedPackage, service *expr.HTTPServiceExpr, client bool) error { + imports := []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("errors"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("io"), + codegen.SimpleImport("mime/multipart"), + codegen.SimpleImport("net/http"), + codegen.SimpleImport("strconv"), + codegen.SimpleImport("strings"), + codegen.SimpleImport("unicode/utf8"), + codegen.SimpleImport("github.com/gorilla/websocket"), + codegen.GoaImport(""), + codegen.GoaNamedImport("http", "goahttp"), + } + if client { + imports = append(imports, + codegen.SimpleImport("bytes"), + codegen.SimpleImport("net/url"), + codegen.SimpleImport("os"), + codegen.SimpleImport("time"), + ) + } else { + imports = append(imports, + codegen.SimpleImport("bufio"), + codegen.SimpleImport("path"), + ) + } + hasStream := false + for _, endpoint := range service.HTTPEndpoints { + if endpoint.UsesWebSocket() || endpoint.UsesSSE() { + hasStream = true + } + if client && endpoint.IsJSONRPC() { + imports = append(imports, + codegen.SimpleImport("github.com/google/uuid"), + codegen.GoaImport("jsonrpc"), + ) + } + } + if hasStream { + imports = append(imports, codegen.SimpleImport("sync")) + if !client { + imports = append(imports, codegen.SimpleImport("time")) + } + } + for _, spec := range imports { + if err := outputPackage.RequireImport(spec); err != nil { + return err + } + } + return nil +} + +// requireHTTPCLIImports records the fixed imports used by a generated command +// parser and its payload builders. +func requireHTTPCLIImports(outputPackage *codegen.GeneratedPackage) error { + for _, spec := range []*codegen.ImportSpec{ + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("flag"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("net/http"), + codegen.SimpleImport("os"), + codegen.SimpleImport("strconv"), + codegen.SimpleImport("unicode/utf8"), + codegen.GoaImport(""), + codegen.GoaNamedImport("http", "goahttp"), + } { + if err := outputPackage.RequireImport(spec); err != nil { + return err + } + } + return nil +} + +// serviceHasMultipartRequest reports whether the example package writes a +// multipart callback whose signature uses this service's generated server. +func serviceHasMultipartRequest(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.MultipartRequest { + return true + } + } + return false +} + +// newPlans validates the full input set and submits names for every plan. +func newPlans(generation *codegen.Generation, transport transportKind, inputs []PlanInput) ([]*Plan, error) { + if generation == nil { + return nil, fmt.Errorf("HTTP plans require a generation") + } + if generation.Frozen() { + return nil, fmt.Errorf("HTTP plans must be collected before generation freeze") + } + owned := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + root, ok := candidate.(*expr.RootExpr) + if ok && len(transportExpressions(root, transport).Services) > 0 { + owned[root] = struct{}{} + } + } + seen := make(map[*expr.RootExpr]struct{}, len(inputs)) + for _, input := range inputs { + if input.Root == nil { + return nil, fmt.Errorf("HTTP plan requires a prepared design root") + } + if input.Service == nil { + return nil, fmt.Errorf("HTTP plan requires a service plan") + } + if input.Service.Root() != input.Root { + return nil, fmt.Errorf("%s root does not match its service plan root", transportLabel(transport)) + } + if _, ok := owned[input.Root]; !ok { + return nil, fmt.Errorf("%s root %p is not a transport root owned by generation", transportLabel(transport), input.Root) + } + if _, ok := seen[input.Root]; ok { + return nil, fmt.Errorf("%s root %p is planned more than once", transportLabel(transport), input.Root) + } + seen[input.Root] = struct{}{} + } + if len(inputs) != len(owned) { + return nil, fmt.Errorf("%s planning requires all %d transport roots, got %d", transportLabel(transport), len(owned), len(inputs)) + } + packages := make(map[string]*wireTypeCatalog) + plans := make([]*Plan, len(inputs)) + for index, input := range inputs { + plan, err := newPlan(generation, transport, input, packages) + if err != nil { + return nil, err + } + plans[index] = plan + } + if err := planImports(generation, transport, plans); err != nil { + return nil, err + } + for _, catalog := range packages { + if err := catalog.Declare(); err != nil { + return nil, err + } + } + for _, plan := range plans { + for _, serviceTypes := range plan.wireTypes { + for endpoint, record := range serviceTypes.streamPayloads { + plan.streams[endpoint] = record.constructor + } + } + } + return plans, nil +} + +// newPlan records one design's HTTP services and submits every function name +// that its generated client and server packages will define. +func newPlan(generation *codegen.Generation, transport transportKind, input PlanInput, packages map[string]*wireTypeCatalog) (*Plan, error) { + plan := &Plan{ + root: input.Root, + servicePlan: input.Service, + generation: generation, + transport: transport, + serverPackages: make(map[*expr.HTTPServiceExpr]*codegen.GeneratedPackage), + extensions: make(map[*expr.HTTPServiceExpr]*serverExtensions), + constructors: make(map[viewedConstructorKey]*codegen.NameDeclaration), + payloads: make(map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration), + streams: make(map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration), + errors: make(map[*expr.HTTPErrorExpr]*codegen.NameDeclaration), + wireTypes: make(map[*expr.HTTPServiceExpr]*plannedWireTypes), + symbols: make(map[*expr.HTTPServiceExpr]*httpSymbols), + servicePaths: make(map[*expr.HTTPServiceExpr]string), + cliParsers: make(map[string]*cli.ParserPlan), + fileImports: make(map[string]*plannedFileImports), + } + expressions := transportExpressions(input.Root, transport) + dir := transportDirectory(transport) + for _, transportService := range expressions.Services { + servicePackage, _, err := input.Service.ServicePackageImports(transportService.ServiceExpr) + if err != nil { + return nil, err + } + servicePath := path.Base(servicePackage.Path) + plan.servicePaths[transportService] = servicePath + clientPath := path.Join(generation.GenPkg(), dir, servicePath, "client") + clientPackage, err := generation.ClaimPackage(clientPath) + if err != nil { + return nil, err + } + serverPath := path.Join(generation.GenPkg(), dir, servicePath, "server") + serverPackage, err := generation.ClaimPackage(serverPath) + if err != nil { + return nil, err + } + plan.serverPackages[transportService] = serverPackage + plan.extensions[transportService] = &serverExtensions{ + endpointHandlerWrappers: make(map[*expr.HTTPEndpointExpr][]*codegen.NameDeclaration), + } + clientCatalog := packages[clientPath] + if clientCatalog == nil { + clientCatalog = newWireTypeCatalog(clientPackage) + packages[clientPath] = clientCatalog + } + serverCatalog := packages[serverPath] + if serverCatalog == nil { + serverCatalog = newWireTypeCatalog(serverPackage) + packages[serverPath] = serverCatalog + } + planned := &plannedWireTypes{ + server: serverCatalog, + client: clientCatalog, + transforms: plannedWireTransforms{ + requests: make(map[clientBodyConstructorKey]*plannedRequestTransforms), + responses: make(map[viewedConstructorKey]*plannedResponseTransforms), + errors: make(map[*expr.HTTPErrorExpr]*plannedResponseTransforms), + streamingResults: make(map[*expr.HTTPEndpointExpr]*plannedResponseTransforms), + }, + streamPayloads: make(map[*expr.HTTPEndpointExpr]*wireTypeRecord), + clientBodyConstructors: make(map[clientBodyConstructorKey]*codegen.NameDeclaration), + clientBodyConstructorNames: make(map[clientBodyConstructorKey]string), + } + collectPlannedWireTypes(input.Root.API.Name, transportService, planned, input.Service) + plan.wireTypes[transportService] = planned + symbols, err := collectHTTPSymbols(plan, transportService, clientPackage, serverPackage) + if err != nil { + return nil, err + } + plan.symbols[transportService] = symbols + for _, endpoint := range transportService.HTTPEndpoints { + order := viewedConstructorOrder{ + transport: dir, + api: input.Root.API.Name, + service: transportService.Name(), + method: endpoint.Name(), + } + for _, role := range []wireTypeRole{wireRequestBody, wireStreamPayload} { + key := clientBodyConstructorKey{endpoint: endpoint, role: role} + preferred := planned.clientBodyConstructorNames[key] + if preferred == "" { + continue + } + body := planned.bodies.request(endpoint) + if role == wireStreamPayload { + body = planned.bodies.streaming(endpoint) + } + preferred = planned.client.releasedCompositeConstructorName(body, jsonBodyPolicy(true, false, false, "")) + orderRole := "request body" + if role == wireStreamPayload { + orderRole = "streaming body" + } + declaration, err := declareHTTPConstructor(clientPackage, preferred, order.withRole(orderRole)) + if err != nil { + return nil, err + } + planned.clientBodyConstructors[key] = declaration + } + if needInit(endpoint.MethodExpr.Payload.Type) { + declaration, err := declareHTTPConstructor(serverPackage, endpointPayloadConstructorName(endpoint), order.withRole("payload")) + if err != nil { + return nil, err + } + plan.payloads[endpoint] = declaration + } + if endpoint.UsesWebSocket() && endpoint.MethodExpr.StreamingPayload.Type != expr.Empty && needInit(endpoint.MethodExpr.StreamingPayload.Type) && planned.streamPayloads[endpoint] == nil { + preferred := "New" + codegen.Goify(endpoint.Name(), true) + codegen.Goify(endpoint.MethodExpr.StreamingPayload.Type.Name(), true) + declaration, err := declareHTTPConstructor(serverPackage, preferred, order.withRole("streaming payload")) + if err != nil { + return nil, err + } + plan.streams[endpoint] = declaration + } + if needInit(endpoint.MethodExpr.Result.Type) { + resultType, viewed := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr) + noTagSeen := false + for _, response := range endpoint.Responses { + if response.Tag[0] == "" { + if noTagSeen { + continue + } + noTagSeen = true + } + views := []string{""} + body := planned.bodies.response(response) + _, explicitBody := body.Meta["origin:attribute"] + if viewed && !explicitBody && clientResponseViewNameExpr(endpoint, resultType) == "" && (endpoint.UsesSSE() || endpoint.IsJSONRPC()) { + views = make([]string, len(resultType.Views)) + for index, view := range resultType.Views { + views[index] = view.Name + } + } + for _, view := range views { + key := viewedConstructorKey{endpoint: endpoint, response: response, view: view} + responseOrder := order.withRole("result") + responseOrder.status = response.StatusCode + responseOrder.tagName = response.Tag[0] + responseOrder.tagValue = response.Tag[1] + responseOrder.view = view + declaration, err := declareHTTPConstructor(clientPackage, viewedResultConstructorName(endpoint, response, view), responseOrder) + if err != nil { + return nil, err + } + plan.constructors[key] = declaration + } + } + } + for _, transportError := range endpoint.HTTPErrors { + if !needInit(transportError.Type) { + continue + } + errorOrder := order.withRole("error") + errorOrder.status = transportError.Response.StatusCode + errorOrder.tagName = transportError.Name + preferred := "New" + codegen.Goify(endpoint.Name(), true) + codegen.Goify(transportError.ErrorExpr.Name, true) + declaration, err := declareHTTPConstructor(clientPackage, preferred, errorOrder) + if err != nil { + return nil, err + } + plan.errors[transportError] = declaration + } + } + } + for _, server := range input.Root.API.Servers { + serverPath := path.Join(generation.GenPkg(), dir, "cli", codegen.SnakeCase(codegen.Goify(server.Name, true))) + serverPackage, err := generation.ClaimPackage(serverPath) + if err != nil { + return nil, err + } + var commands []cli.CommandDeclarationInput + for _, serviceName := range server.Services { + transportService := expressions.Service(serviceName) + if transportService == nil || len(transportService.HTTPEndpoints) == 0 { + continue + } + command := cli.CommandDeclarationInput{Service: serviceName} + for _, endpoint := range transportService.HTTPEndpoints { + command.Methods = append(command.Methods, endpoint.MethodExpr.Name) + } + commands = append(commands, command) + } + parser, err := cli.DeclareParser(serverPackage, dir, input.Root.API.Name, server.Name, commands) + if err != nil { + return nil, err + } + plan.cliParsers[server.Name] = parser + } + return plan, nil +} + +// link reads the generated service names, builds data for every selected service once, +// and stores all files that the public methods on Plan return. +func (p *Plan) link() error { + serviceData := p.servicePlan.Services() + if serviceData.Root != p.root { + return fmt.Errorf("HTTP plan root does not match linked service plan root") + } + expressions := transportExpressions(p.root, p.transport) + services := newServicesData(serviceData, expressions) + services.jsonrpc = p.transport == jsonrpcTransport + services.viewedResultConstructors = p.constructors + services.payloadConstructors = p.payloads + services.streamConstructors = p.streams + services.errorConstructors = p.errors + services.plannedWireTypes = p.wireTypes + services.plannedSymbols = p.symbols + services.cliParsers = p.cliParsers + services.fileImports = make(map[string][]*codegen.ImportSpec, len(p.fileImports)) + for filePath, retained := range p.fileImports { + imports := make([]*codegen.ImportSpec, len(retained.paths)) + for index, importPath := range retained.paths { + imports[index] = retained.output.Import(importPath) + } + services.fileImports[filePath] = imports + } + for _, transportService := range services.Expressions.Services { + if services.ServicesData.Get(transportService.Name()) == nil { + return fmt.Errorf("HTTP service %q has no linked service model", transportService.Name()) + } + data := services.analyze(transportService) + if services.linkErr != nil { + return services.linkErr + } + extensions := p.extensions[transportService] + data.ServerHandlerWrappers = append([]*codegen.NameDeclaration(nil), extensions.handlerWrappers...) + for index, endpoint := range data.Endpoints { + endpoint.ServerHandlerWrappers = combinedHandlerWrappers(extensions, transportService.HTTPEndpoints[index]) + } + for _, fileServer := range data.FileServers { + fileServer.ServerHandlerWrappers = append([]*codegen.NameDeclaration(nil), extensions.handlerWrappers...) + } + data.ServerMounts = copyServerMounts(extensions.mounts) + services.HTTPData[transportService.Name()] = data + } + for _, planned := range p.wireTypes { + if err := planned.checkTransformsUsed(); err != nil { + return err + } + } + p.services = services + p.viewed = make(map[viewedMethodKey]*viewedResultPlan) + p.jsonServices = make(map[string]*jsonRPCServicePlan, len(services.HTTPData)) + for serviceName, serviceData := range services.HTTPData { + transportService := expressions.Service(serviceName) + if transportService == nil { + return fmt.Errorf("HTTP service %q has no transport expression", serviceName) + } + if len(transportService.HTTPEndpoints) != len(serviceData.Endpoints) { + return fmt.Errorf("HTTP service %q endpoint analysis does not match its design", serviceName) + } + jsonService := &jsonRPCServicePlan{ + data: serviceData, + fileImports: make(map[string][]*codegen.ImportSpec), + clientCodec: clientEncodeDecodeFile(transportService, services), + serverCodec: serverEncodeDecodeFile(transportService, services), + } + servicePackage, viewsPackage, err := servicePackagePreferences(p.servicePlan, transportService) + if err != nil { + return err + } + planned := p.wireTypes[transportService] + jsonService.clientServiceImport = planned.client.pkg.Import(servicePackage.Path) + jsonService.serverServiceImport = planned.server.pkg.Import(servicePackage.Path) + if viewsPackage != nil { + jsonService.clientViewImport = planned.client.pkg.Import(viewsPackage.Path) + jsonService.serverViewImport = planned.server.pkg.Import(viewsPackage.Path) + } + if p.transport == jsonrpcTransport { + jsonService.prepareFileImports(services) + } + p.jsonServices[serviceName] = jsonService + for _, endpoint := range serviceData.Endpoints { + if endpoint.Method.ViewedResult == nil || endpoint.SSE == nil && !endpoint.IsJSONRPC { + continue + } + var representations []viewedRepresentationPlan + for _, response := range endpoint.Result.Responses { + for _, representation := range response.ViewedRepresentations { + representations = append(representations, viewedRepresentationPlan{ + data: representation, + headers: response.Headers, + cookies: response.Cookies, + }) + } + } + variable := endpoint.Method.ViewedResult.ViewName == "" + if len(representations) == 0 { + return fmt.Errorf("HTTP viewed method %q has no response representations", endpoint.Method.Name) + } + p.viewed[viewedMethodKey{service: serviceName, method: endpoint.Method.Name}] = &viewedResultPlan{ + variable: variable, + fixedView: endpoint.Method.ViewedResult.ViewName, + service: endpoint.Method.ViewedResult, + representations: representations, + } + } + } + if p.transport == httpTransport { + p.server = serverFiles(services) + p.client = clientFiles(services) + } + p.serverTypes = serverTypeFiles(services) + p.clientTypes = clientTypeFiles(services) + p.paths = pathFiles(services) + p.clientCLI = clientCLIFiles(services) + return nil +} + +// checkTransformsUsed verifies that every conversion retained by this service +// plan was written exactly once while its template data was built. +func (p *plannedWireTypes) checkTransformsUsed() error { + checkRequest := func(transforms *plannedRequestTransforms) error { + for _, use := range []struct { + catalog *wireTypeCatalog + handle wireTransformHandle + }{ + {p.client, transforms.clientEncode}, + {p.server, transforms.serverDecode}, + {p.client, transforms.clientDecode}, + } { + if err := use.catalog.checkTransformUsed(use.handle); err != nil { + return err + } + } + return nil + } + checkResponse := func(transforms *plannedResponseTransforms) error { + for _, use := range []struct { + catalog *wireTypeCatalog + handle wireTransformHandle + }{ + {p.server, transforms.serverEncode}, + {p.client, transforms.clientDecode}, + } { + if err := use.catalog.checkTransformUsed(use.handle); err != nil { + return err + } + } + return nil + } + for _, transforms := range p.transforms.requests { + if err := checkRequest(transforms); err != nil { + return err + } + } + for _, transforms := range p.transforms.responses { + if err := checkResponse(transforms); err != nil { + return err + } + } + for _, transforms := range p.transforms.errors { + if err := checkResponse(transforms); err != nil { + return err + } + } + for _, transforms := range p.transforms.streamingResults { + if err := checkResponse(transforms); err != nil { + return err + } + } + return nil +} + +// combinedHandlerWrappers copies the service wrappers followed by the wrappers +// declared for one endpoint. Templates nest the first entry outermost. +func combinedHandlerWrappers(extensions *serverExtensions, endpoint *expr.HTTPEndpointExpr) []*codegen.NameDeclaration { + wrappers := make([]*codegen.NameDeclaration, 0, len(extensions.handlerWrappers)+len(extensions.endpointHandlerWrappers[endpoint])) + wrappers = append(wrappers, extensions.handlerWrappers...) + return append(wrappers, extensions.endpointHandlerWrappers[endpoint]...) +} + +// copyServerMounts gives render code its own mount functions and route entries. +func copyServerMounts(source []*ServerMount) []*ServerMount { + result := make([]*ServerMount, len(source)) + for index, mount := range source { + result[index] = &ServerMount{ + Declaration: mount.Declaration, + MountPoints: append([]ServerMountPoint(nil), mount.MountPoints...), + } + } + return result +} + +// transportExpressions returns the HTTP or JSON-RPC designs requested +// by the caller. +func transportExpressions(root *expr.RootExpr, transport transportKind) *expr.HTTPExpr { + if transport == jsonrpcTransport { + return &root.API.JSONRPC.HTTPExpr + } + return root.API.HTTP +} + +// transportDirectory returns the output directory for HTTP or JSON-RPC files. +func transportDirectory(transport transportKind) string { + if transport == jsonrpcTransport { + return "jsonrpc" + } + return "http" +} + +// transportLabel returns "HTTP" or "JSON-RPC" for error messages. +func transportLabel(transport transportKind) string { + if transport == jsonrpcTransport { + return "JSON-RPC" + } + return "HTTP" +} + +// requireLinked rejects file and service access before Link builds them. +func (p *Plan) requireLinked() { + if p.services == nil { + panic("HTTP render model requested before plan linking") + } +} + +// prepareFileImports copies the package paths collected for each JSON-RPC file +// before generation names were frozen. +func (p *jsonRPCServicePlan) prepareFileImports(services *ServicesData) { + servicePath := p.data.Service.PathName + clientPath := path.Join(codegen.Gendir, "jsonrpc", servicePath, "client", "client.go") + serverPath := path.Join(codegen.Gendir, "jsonrpc", servicePath, "server", "server.go") + p.fileImports[clientPath] = cloneImportSpecs(services.fileImports[filepathKey(clientPath)]) + p.fileImports[serverPath] = cloneImportSpecs(services.fileImports[filepathKey(serverPath)]) + if p.clientCodec != nil { + p.fileImports[filepathKey(p.clientCodec.Path)] = cloneImportSpecs(services.fileImports[filepathKey(p.clientCodec.Path)]) + } + if p.serverCodec != nil { + p.fileImports[filepathKey(p.serverCodec.Path)] = cloneImportSpecs(services.fileImports[filepathKey(p.serverCodec.Path)]) + } + hasSSE := false + for _, endpoint := range p.data.Endpoints { + if endpoint.SSE != nil { + hasSSE = true + break + } + } + if hasSSE { + clientStreamPath := path.Join(codegen.Gendir, "jsonrpc", servicePath, "client", "stream.go") + serverStreamPath := path.Join(codegen.Gendir, "jsonrpc", servicePath, "server", "sse.go") + p.fileImports[clientStreamPath] = cloneImportSpecs(services.fileImports[filepathKey(clientStreamPath)]) + p.fileImports[serverStreamPath] = cloneImportSpecs(services.fileImports[filepathKey(serverStreamPath)]) + } +} + +// cloneImportSpecs copies an import list and each import value so a caller can +// change both without changing the list stored by the HTTP plan. +func cloneImportSpecs(source []*codegen.ImportSpec) []*codegen.ImportSpec { + result := make([]*codegen.ImportSpec, len(source)) + for index, spec := range source { + copy := *spec + result[index] = © + } + return result +} + +// cloneImportSpec copies one import so callers can change its path or name +// without changing the import stored by the HTTP plan. +func cloneImportSpec(source *codegen.ImportSpec) *codegen.ImportSpec { + if source == nil { + return nil + } + copy := *source + return © +} + +// viewedResultConstructorName returns the preferred constructor spelling for +// one client response body selected by a result view. +func viewedResultConstructorName(endpoint *expr.HTTPEndpointExpr, response *expr.HTTPResponseExpr, view string) string { + status := codegen.Goify(http.StatusText(response.StatusCode), true) + if view != "" { + return "New" + codegen.Goify(endpoint.Name(), true) + "Result" + codegen.Goify(view, true) + status + } + return releasedMethodTypeConstructorName(endpoint.Name(), releasedMethodTypeName(endpoint.MethodExpr.Result, "Result"), "Result") + status +} + +// endpointPayloadConstructorName returns the preferred server function name +// that builds one method payload from its HTTP request values. +func endpointPayloadConstructorName(endpoint *expr.HTTPEndpointExpr) string { + method := codegen.Goify(endpoint.Name(), true) + payload := codegen.Goify(releasedMethodTypeName(endpoint.MethodExpr.Payload, "Payload"), true) + if strings.HasPrefix(payload, method) { + return "New" + payload + } + return "New" + method + payload +} + +// releasedMethodTypeName returns the service-side spelling used before HTTP +// constructors were planned separately. It specializes arrays and maps from +// their element types, such as ElemType and MapKeyTypeElemType. +func releasedMethodTypeName(attribute *expr.AttributeExpr, role string) string { + name := codegen.NewNameScope().GoTypeName(attribute) + if name == "" { + return role + } + return name +} + +// releasedMethodTypeConstructorName joins a method and its service type while +// avoiding a repeated type stem. For example, FetchCustomer and Customer +// produce NewFetchCustomerResult. +func releasedMethodTypeConstructorName(method, typeName, role string) string { + method = codegen.Goify(method, true) + typeName = codegen.Goify(typeName, true) + stem := strings.TrimSuffix(typeName, role) + if stem != typeName && stem != "" && strings.HasSuffix(method, stem) { + return "New" + method + role + } + return "New" + method + typeName +} + +// declareHTTPConstructor submits one constructor name to the generated package +// that will contain both its definition and calls. +func declareHTTPConstructor(pkg *codegen.GeneratedPackage, preferred string, order viewedConstructorOrder) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(codegen.NameFunction, preferred, codegen.ExportedName, order) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil +} + +// withRole returns an ordering value for one kind of endpoint constructor. +func (o viewedConstructorOrder) withRole(role string) viewedConstructorOrder { + o.role = role + return o +} + +// ComparePackageName orders view constructors by design service, method, +// response status, and view so input iteration order cannot change names. +func (o viewedConstructorOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(viewedConstructorOrder) + for _, compared := range []int{ + cmp.Compare(o.transport, right.transport), + cmp.Compare(o.api, right.api), + cmp.Compare(o.service, right.service), + cmp.Compare(o.method, right.method), + cmp.Compare(o.role, right.role), + cmp.Compare(o.status, right.status), + cmp.Compare(o.tagName, right.tagName), + cmp.Compare(o.tagValue, right.tagValue), + cmp.Compare(o.view, right.view), + } { + if compared != 0 { + return compared + } + } + return 0 +} diff --git a/http/codegen/plan_extensions_test.go b/http/codegen/plan_extensions_test.go new file mode 100644 index 0000000000..e6febfc0fa --- /dev/null +++ b/http/codegen/plan_extensions_test.go @@ -0,0 +1,180 @@ +// This file checks the handler wrappers and extra routes that plugins declare +// before Goa chooses generated package names. +package codegen + +import ( + "cmp" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type extensionNameOrder string + +// ComparePackageName gives extension declarations a stable order in tests. +func (o extensionNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + return cmp.Compare(string(o), string(other.(extensionNameOrder))) +} + +func TestPlanDeclaresServerExtensions(t *testing.T) { + root := extensionRoot(t) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + serviceExpr := root.API.HTTP.Services[0] + + first, err := plan.DeclareServerHandlerWrapper(serviceExpr, "WrapHandler", extensionNameOrder("first")) + require.NoError(t, err) + second, err := plan.DeclareServerHandlerWrapper(serviceExpr, "WrapHandler", extensionNameOrder("second")) + require.NoError(t, err) + endpointWrapper, err := plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "wrapEndpoint", extensionNameOrder("endpoint")) + require.NoError(t, err) + secondEndpointWrapper, err := plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "wrapEndpoint", extensionNameOrder("second endpoint")) + require.NoError(t, err) + descriptions := []ServerMountPoint{{Method: "Preflight", Verb: "OPTIONS", Pattern: "/items/{id}"}} + mount, err := plan.DeclareServerMount(serviceExpr, "MountPreflight", extensionNameOrder("mount"), descriptions) + require.NoError(t, err) + descriptions[0].Method = "changed" + + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + data := plan.services.Get("Files") + require.Equal(t, "WrapHandler", first.Name()) + require.Equal(t, "WrapHandler2", second.Name()) + require.Equal(t, []*codegen.NameDeclaration{first, second}, data.ServerHandlerWrappers) + require.Equal(t, "wrapEndpoint", endpointWrapper.Name()) + require.Equal(t, "wrapEndpoint2", secondEndpointWrapper.Name()) + require.Equal(t, []*codegen.NameDeclaration{first, second, endpointWrapper, secondEndpointWrapper}, data.Endpoints[0].ServerHandlerWrappers) + for _, fileServer := range data.FileServers { + require.Equal(t, []*codegen.NameDeclaration{first, second}, fileServer.ServerHandlerWrappers) + } + require.Equal(t, mount, data.ServerMounts[0].Declaration) + require.Equal(t, []ServerMountPoint{{Method: "Preflight", Verb: "OPTIONS", Pattern: "/items/{id}"}}, data.ServerMounts[0].MountPoints) +} + +func TestPlanRejectsInvalidServerExtensions(t *testing.T) { + root := extensionRoot(t) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + serviceExpr := root.API.HTTP.Services[0] + foreignRoot := extensionRoot(t) + foreignService := foreignRoot.API.HTTP.Services[0] + foreignEndpoint := foreignService.HTTPEndpoints[0] + + tests := []struct { + name string + call func() error + want string + }{ + {"nil service", func() error { + _, err := plan.DeclareServerHandlerWrapper(nil, "Wrap", extensionNameOrder("nil")) + return err + }, "HTTP server extension requires a service from this plan"}, + {"foreign service", func() error { + _, err := plan.DeclareServerHandlerWrapper(foreignService, "Wrap", extensionNameOrder("foreign")) + return err + }, "HTTP service does not belong to this plan"}, + {"nil endpoint", func() error { + _, err := plan.DeclareServerEndpointHandlerWrapper(nil, "wrap", extensionNameOrder("nil endpoint")) + return err + }, "HTTP server endpoint wrapper requires an endpoint from this plan"}, + {"foreign endpoint", func() error { + _, err := plan.DeclareServerEndpointHandlerWrapper(foreignEndpoint, "wrap", extensionNameOrder("foreign endpoint")) + return err + }, "HTTP endpoint does not belong to this plan"}, + {"empty preferred name", func() error { + _, err := plan.DeclareServerHandlerWrapper(serviceExpr, "", extensionNameOrder("empty")) + return err + }, "package name must not be empty"}, + {"nil order", func() error { + _, err := plan.DeclareServerHandlerWrapper(serviceExpr, "Wrap", nil) + return err + }, `generated package "generated.local/gen/http/files/server" cannot declare preferred function "Wrap": package name order must be a stable concrete named value type`}, + {"no mount descriptions", func() error { + _, err := plan.DeclareServerMount(serviceExpr, "Mount", extensionNameOrder("none"), nil) + return err + }, "HTTP server mount requires at least one mount point"}, + {"empty method", func() error { + _, err := plan.DeclareServerMount(serviceExpr, "Mount", extensionNameOrder("method"), []ServerMountPoint{{Verb: "OPTIONS", Pattern: "/"}}) + return err + }, "HTTP server mount point 0 has an empty method"}, + {"empty verb", func() error { + _, err := plan.DeclareServerMount(serviceExpr, "Mount", extensionNameOrder("verb"), []ServerMountPoint{{Method: "Preflight", Pattern: "/"}}) + return err + }, "HTTP server mount point 0 has an empty verb"}, + {"empty pattern", func() error { + _, err := plan.DeclareServerMount(serviceExpr, "Mount", extensionNameOrder("pattern"), []ServerMountPoint{{Method: "Preflight", Verb: "OPTIONS"}}) + return err + }, "HTTP server mount point 0 has an empty pattern"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.EqualError(t, test.call(), test.want) + }) + } + + require.NoError(t, generation.Freeze()) + _, err := plan.DeclareServerHandlerWrapper(serviceExpr, "Late", extensionNameOrder("late")) + require.EqualError(t, err, "HTTP server extension cannot be declared after generation freeze") + _, err = plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "late", extensionNameOrder("late endpoint")) + require.EqualError(t, err, "HTTP server extension cannot be declared after generation freeze") + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + _, err = plan.DeclareServerHandlerWrapper(serviceExpr, "Linked", extensionNameOrder("linked")) + require.EqualError(t, err, "HTTP server extension cannot be declared after plan linking") + _, err = plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "linked", extensionNameOrder("linked endpoint")) + require.EqualError(t, err, "HTTP server extension cannot be declared after plan linking") +} + +func TestJSONRPCPlanRejectsServerExtensions(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("RPC", func() { + dsl.Method("Read", func() { dsl.JSONRPC(func() {}) }) + }) + }) + plan, _, _ := plannedHTTPPlan(t, root, true) + _, err := plan.DeclareServerHandlerWrapper(root.API.JSONRPC.Services[0], "Wrap", extensionNameOrder("rpc")) + require.EqualError(t, err, "JSON-RPC HTTP plans do not support server extensions") + _, err = plan.DeclareServerEndpointHandlerWrapper(root.API.JSONRPC.Services[0].HTTPEndpoints[0], "wrap", extensionNameOrder("rpc endpoint")) + require.EqualError(t, err, "JSON-RPC HTTP plans do not support server extensions") +} + +// extensionRoot builds the HTTP service used to test endpoint and file wrappers. +func extensionRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.Service("Files", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { dsl.Attribute("id", dsl.String) }) + dsl.HTTP(func() { dsl.GET("/items/{id}") }) + }) + dsl.Files("/assets/{*path}", "assets") + dsl.Files("/old", "old.html", func() { dsl.Redirect("/new", 301) }) + }) + }) +} + +// plannedHTTPPlan creates an HTTP or JSON-RPC plan without linking it so each +// test can add server functions before generated names become final. +func plannedHTTPPlan(t *testing.T, root *expr.RootExpr, jsonrpc bool) (*Plan, *codegen.Generation, *service.Plan) { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + input := PlanInput{Root: root, Service: servicePlan} + var plans []*Plan + if jsonrpc { + plans, err = NewJSONRPCPlans(generation, input) + } else { + plans, err = NewPlans(generation, input) + } + require.NoError(t, err) + require.Len(t, plans, 1) + return plans[0], generation, servicePlan +} diff --git a/http/codegen/plan_service_test.go b/http/codegen/plan_service_test.go new file mode 100644 index 0000000000..2047413ba3 --- /dev/null +++ b/http/codegen/plan_service_test.go @@ -0,0 +1,60 @@ +// This file checks that plugins can read only the finalized HTTP service data +// that belongs to the exact service expression used to build a retained plan. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestPlanServiceRequiresLink(t *testing.T) { + root := serviceLookupRoot(t) + plan, _, _ := plannedHTTPPlan(t, root, false) + + require.PanicsWithValue(t, "HTTP render model requested before plan linking", func() { + plan.Service(root.API.HTTP.Services[0]) + }) +} + +func TestPlanServiceUsesExactExpression(t *testing.T) { + root := serviceLookupRoot(t) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + alpha, ok := plan.Service(root.API.HTTP.Services[0]) + require.True(t, ok) + require.Equal(t, "Alpha", alpha.Service.Name) + + beta, ok := plan.Service(root.API.HTTP.Services[1]) + require.True(t, ok) + require.Equal(t, "Beta", beta.Service.Name) + require.NotSame(t, alpha, beta) + + foreign := serviceLookupRoot(t) + _, ok = plan.Service(foreign.API.HTTP.Services[0]) + require.False(t, ok) +} + +// serviceLookupRoot creates two services so the test can distinguish exact +// service identity from a lookup by a repeated name or position. +func serviceLookupRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.Service("Alpha", func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/alpha") }) + }) + }) + dsl.Service("Beta", func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/beta") }) + }) + }) + }) +} diff --git a/http/codegen/plan_test.go b/http/codegen/plan_test.go new file mode 100644 index 0000000000..8b9261ef9b --- /dev/null +++ b/http/codegen/plan_test.go @@ -0,0 +1,809 @@ +// This file verifies that HTTP package names are requested before Goa assigns +// them and that files are built only after service names are available. +package codegen + +import ( + "fmt" + "path" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestPlanReservesStaticAliasesBeforeFreeze(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Path", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() + + clientOutput := "generated.local/gen/http/path/client" + require.Equal(t, "path", services.ServiceImport(clientOutput, "Path").Name) + serverOutput := "generated.local/gen/http/path/server" + require.Equal(t, "path2", services.ServiceImport(serverOutput, "Path").Name) +} + +func TestPlanRejectsFrozenGeneration(t *testing.T) { + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + _, err = NewPlans(generation) + require.Error(t, err) +} + +func TestEndpointPayloadConstructorUsesReleasedTypeName(t *testing.T) { + cases := []struct { + name string + method string + payload string + want string + }{ + { + name: "named payload", + method: "MethodBodyUnion", + payload: "Union", + want: "NewMethodBodyUnionUnion", + }, + { + name: "overlapping payload", + method: "MethodA", + payload: "APayload", + want: "NewMethodAAPayload", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + endpoint := &expr.HTTPEndpointExpr{MethodExpr: &expr.MethodExpr{ + Name: test.method, + Payload: &expr.AttributeExpr{Type: wireCatalogType(test.payload, test.payload, "value", true)}, + }} + require.Equal(t, test.want, endpointPayloadConstructorName(endpoint)) + }) + } +} + +func TestViewedResultConstructorUsesReleasedTypeName(t *testing.T) { + endpoint := &expr.HTTPEndpointExpr{MethodExpr: &expr.MethodExpr{ + Name: "MethodBodyInlineObject", + Result: &expr.AttributeExpr{Type: wireCatalogType("ResultType", "result", "value", true)}, + }} + response := &expr.HTTPResponseExpr{StatusCode: 200} + + require.Equal(t, "NewMethodBodyInlineObjectResultTypeOK", viewedResultConstructorName(endpoint, response, "")) + require.Equal(t, "NewMethodBodyInlineObjectResultTinyOK", viewedResultConstructorName(endpoint, response, "tiny")) +} + +// TestNewExamplePlanRejectsAnotherServicePlan checks that server names and +// URLs cannot come from a different design with the same authored names. +func TestNewExamplePlanRejectsAnotherServicePlan(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Service", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/") }) }) + }) + }) + transport := linkedHTTPPlanForRoot(t, root) + + otherRoot := codegen.RunDSL(t, func() { + dsl.Service("Service", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/") }) }) + }) + }) + otherGeneration, err := codegen.NewGeneration("other.local/gen", []eval.Root{otherRoot}) + require.NoError(t, err) + otherService, err := service.NewPlan(otherRoot, otherGeneration, expr.NewExampleGenerator(otherRoot.API.RandomizerFactory)) + require.NoError(t, err) + examples, err := example.NewPlan(otherGeneration, otherService) + require.NoError(t, err) + + _, err = NewExamplePlan(transport, examples) + require.EqualError(t, err, "HTTP examples require server data created from the same service design") +} + +// TestNewPlansRequiresEveryHTTPRoot proves package names cannot be requested +// from only some of the HTTP designs in one generation. +func TestNewPlansRequiresEveryHTTPRoot(t *testing.T) { + first := expr.RunDSL(t, func() { + dsl.Service("First", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/first") }) }) + }) + }) + second := expr.RunDSL(t, func() { + dsl.Service("Second", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/second") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) + require.NoError(t, err) + services, err := service.NewPlans(generation, + service.PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + service.PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.NoError(t, err) + + _, err = NewPlans(generation, PlanInput{Root: first, Service: services[0]}) + require.EqualError(t, err, "HTTP planning requires all 2 transport roots, got 1") +} + +// TestNewPlansRejectsDuplicateRoot proves one service plan cannot be paired +// with the same design twice in one call. +func TestNewPlansRejectsDuplicateRoot(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/calc") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + + _, err = NewPlans(generation, + PlanInput{Root: root, Service: servicePlan}, + PlanInput{Root: root, Service: servicePlan}, + ) + require.EqualError(t, err, fmt.Sprintf("HTTP root %p is planned more than once", root)) +} + +// TestPlanReservesGeneratedHTTPPackages verifies that client, server, and CLI +// packages receive distinct import names before files are written. +func TestPlanReservesGeneratedHTTPPackages(t *testing.T) { + root := expr.RunDSL(t, func() { + for _, name := range []string{"Foo", "Fooc", "Foosvr"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/" + name) }) + }) + }) + } + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() + + cliOutput := path.Join( + "generated.local/gen/http/cli", + codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true)), + ) + client := services.PackageImport(cliOutput, "generated.local/gen/http/foo/client") + serverOutput := path.Join("generated.local", "cmd", codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true))) + server := services.PackageImport(serverOutput, "generated.local/gen/http/foo/server") + require.Equal(t, "fooc", client.Name) + require.Equal(t, "foosvr", server.Name) +} + +// TestPlanLinkEagerlyRetainsHTTPFiles proves Link analyzes every HTTP service +// once and decides which generated files exist before callers request them. +func TestPlanLinkEagerlyRetainsHTTPFiles(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(func() { dsl.Attribute("value", dsl.Int) }) + dsl.Result(func() { dsl.Attribute("total", dsl.Int) }) + dsl.HTTP(func() { dsl.POST("/add") }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plan := plans[0] + require.NoError(t, plan.Link()) + examples, err := NewExamplePlan(plan, examplePlan) + require.NoError(t, err) + _, ok := plan.JSONRPCService("Calc") + require.True(t, ok) + require.NotEmpty(t, examples.ServerFiles()) + require.NotEmpty(t, examples.CLIFiles()) + serverCount := len(plan.ServerFiles()) + clientCount := len(plan.ClientFiles()) + + root.API.HTTP.Services = append(root.API.HTTP.Services, &expr.HTTPServiceExpr{}) + require.Len(t, plan.ServerFiles(), serverCount) + require.Len(t, plan.ClientFiles(), clientCount) +} + +// TestJSONRPCCodecFilesAreIndependent checks that the JSON-RPC file writer can +// change a returned encoder and decoder file without changing a later copy. +func TestJSONRPCCodecFilesAreIndependent(t *testing.T) { + root := expr.RunDSL(t, func() { + value := dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", "example.com/types") + dsl.Attribute("number", dsl.Int) + }) + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(value) + dsl.Result(func() { dsl.Attribute("total", dsl.Int) }) + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + service, ok := plans[0].JSONRPCService("Calc") + require.True(t, ok) + serverBody := service.Endpoints[0].Payload.Request.ServerBody + require.NotNil(t, serverBody.Declaration) + require.Equal(t, serverBody.Declaration.Name(), serverBody.VarName) + stored := plans[0].jsonServices["Calc"] + assertIndependentCodecFile(t, stored.clientCodec, service.ClientCodecFile) + assertIndependentCodecFile(t, stored.serverCodec, service.ServerCodecFile) + + clientPath := stored.clientCodec.Path + imports := service.FileImports(clientPath) + require.NotEmpty(t, imports) + require.Equal(t, imports, service.FileImports(strings.ReplaceAll(clientPath, "/", `\`))) + original := *imports[0] + imports[0].Path = "changed.example/package" + freshImports := service.FileImports(clientPath) + require.Equal(t, original, *freshImports[0]) + require.PanicsWithValue(t, "JSON-RPC file is not part of this HTTP service plan", func() { + service.FileImports("gen/jsonrpc/calc/client/unknown.go") + }) + + service.Service.Name = "changed" + service.Endpoints[0].ServiceName = "changed" + service.Endpoints[0].Payload.Request.Headers = append( + service.Endpoints[0].Payload.Request.Headers, + JSONRPCHeaderData{CanonicalName: "Changed"}, + ) + fresh, ok := plans[0].JSONRPCService("Calc") + require.True(t, ok) + require.Equal(t, "Calc", fresh.Service.Name) + require.Equal(t, "Calc", fresh.Endpoints[0].ServiceName) + require.Empty(t, fresh.Endpoints[0].Payload.Request.Headers) +} + +// TestPlanRetainsAttributeImportsBeforeFreeze proves ordinary HTTP and +// JSON-RPC files use the type packages recorded during planning, even if the +// design expression is changed before linking. +func TestPlanRetainsAttributeImportsBeforeFreeze(t *testing.T) { + for _, transport := range []struct { + name string + plan func(*codegen.Generation, PlanInput) ([]*Plan, error) + file func(*Plan) []*codegen.ImportSpec + }{ + { + name: "HTTP", + plan: func(generation *codegen.Generation, input PlanInput) ([]*Plan, error) { + return NewPlans(generation, input) + }, + file: func(plan *Plan) []*codegen.ImportSpec { + for _, file := range plan.ClientFiles() { + if strings.HasSuffix(filepath.ToSlash(file.Path), "/client/client.go") { + return file.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec) + } + } + return nil + }, + }, + { + name: "JSON-RPC", + plan: func(generation *codegen.Generation, input PlanInput) ([]*Plan, error) { + return NewJSONRPCPlans(generation, input) + }, + file: func(plan *Plan) []*codegen.ImportSpec { + service, ok := plan.JSONRPCService("Calc") + require.True(t, ok) + return service.FileImports("gen/jsonrpc/calc/client/client.go") + }, + }, + } { + t.Run(transport.name, func(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(func() { + dsl.Attribute("number", dsl.Int, func() { + dsl.Meta("struct:field:type", "values.Number", "example.com/values", "values") + }) + }) + dsl.Result(dsl.Int) + if transport.name == "HTTP" { + dsl.HTTP(func() { dsl.POST("/add") }) + } else { + dsl.JSONRPC(func() {}) + } + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := transport.plan(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + + payload := root.Service("Calc").Method("Add").Payload + delete(expr.AsObject(payload.Type).Attribute("number").Meta, "struct:field:type") + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + imports := transport.file(plans[0]) + require.Contains(t, importPaths(imports), "example.com/values") + }) + } +} + +// importPaths returns the package paths from one generated file header. +func importPaths(imports []*codegen.ImportSpec) []string { + paths := make([]string, len(imports)) + for index, spec := range imports { + paths[index] = spec.Path + } + return paths +} + +// TestJSONRPCSnapshotsExposeReleasedNames checks that copied JSON-RPC data +// gives existing plugins the final Go name stored in each declaration. +func TestJSONRPCSnapshotsExposeReleasedNames(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + for _, name := range []string{"read-data", "read_data"} { + dsl.Method(name, func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.Int) + }) + dsl.Result(dsl.Int) + dsl.JSONRPC(func() {}) + }) + } + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + snapshot, ok := plans[0].JSONRPCService("Calc") + require.True(t, ok) + assertReleasedName(t, snapshot.ServerStruct, snapshot.ServerStructDeclaration) + assertReleasedName(t, snapshot.ServerInit, snapshot.ServerInitDeclaration) + assertReleasedName(t, snapshot.MountServer, snapshot.MountServerDeclaration) + assertReleasedName(t, snapshot.ClientStruct, snapshot.ClientStructDeclaration) + require.Len(t, snapshot.Endpoints, 2) + for index := range snapshot.Endpoints { + endpoint := &snapshot.Endpoints[index] + assertReleasedName(t, endpoint.HandlerInit, endpoint.HandlerInitDeclaration) + assertReleasedName(t, endpoint.ClientStruct, endpoint.ClientStructDeclaration) + assertReleasedName(t, endpoint.RequestEncoder, endpoint.RequestEncoderDeclaration) + assertReleasedName(t, endpoint.RequestDecoder, endpoint.RequestDecoderDeclaration) + assertReleasedName(t, endpoint.ResponseDecoder, endpoint.ResponseDecoderDeclaration) + } + require.NotEqual(t, snapshot.Endpoints[0].HandlerInit, snapshot.Endpoints[1].HandlerInit) +} + +// TestViewedResultSnapshotsPreserveMissingBodies checks that a successful +// response containing only a mapped header keeps both body values absent. It +// also changes the returned header and confirms a later copy is unchanged. +func TestViewedResultSnapshotsPreserveMissingBodies(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.header-view", func() { + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("default", func() { dsl.Attribute("id") }) + }) + dsl.Service("Headers", func() { + dsl.Method("Fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() { + dsl.Response(func() { dsl.Header("id") }) + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + viewed, ok := plans[0].ViewedResult("Headers", "Fetch") + require.True(t, ok) + require.Len(t, viewed.Representations, 1) + require.Nil(t, viewed.Representations[0].ServerBody) + require.Nil(t, viewed.Representations[0].ClientBody) + require.Len(t, viewed.Representations[0].Headers, 1) + originalHeader := viewed.Representations[0].Headers[0].CanonicalName + viewed.Representations[0].Headers[0].CanonicalName = "Changed" + fresh, ok := plans[0].ViewedResult("Headers", "Fetch") + require.True(t, ok) + require.Equal(t, originalHeader, fresh.Representations[0].Headers[0].CanonicalName) +} + +// TestViewedResultCopiesBodyFieldSelection checks that JSON-RPC receives the +// Go field selected by Body("value"). An empty field keeps the whole-result +// body constructor responsible for the server conversion. +func TestViewedResultCopiesBodyFieldSelection(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.body-field", func() { + dsl.TypeName("BodyField") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { dsl.Attribute("value") }) + dsl.View("summary", func() { dsl.Attribute("value") }) + }) + dsl.Service("Values", func() { + dsl.Method("Field", func() { + dsl.Result(result) + dsl.JSONRPC(func() { + dsl.Response(func() { dsl.Body("value") }) + }) + }) + dsl.Method("Whole", func() { + dsl.Result(result) + dsl.JSONRPC(func() { dsl.Response(dsl.StatusOK) }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + field, ok := plans[0].ViewedResult("Values", "Field") + require.True(t, ok) + require.NotEmpty(t, field.Representations) + for _, representation := range field.Representations { + require.Equal(t, "Value", representation.ResultAttr) + require.NotNil(t, representation.ServerBody) + require.Nil(t, representation.ServerBody.Init) + } + + whole, ok := plans[0].ViewedResult("Values", "Whole") + require.True(t, ok) + require.NotEmpty(t, whole.Representations) + for _, representation := range whole.Representations { + require.Empty(t, representation.ResultAttr) + require.NotNil(t, representation.ServerBody) + require.NotNil(t, representation.ServerBody.Init) + } +} + +// TestEndpointConstructorsUsePackageDeclarations checks that request payload, +// response result, and error result functions all use the names chosen before +// files are written. +func TestEndpointConstructorsUsePackageDeclarations(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(func() { dsl.Attribute("value", dsl.Int) }) + dsl.Result(func() { dsl.Attribute("total", dsl.Int) }) + dsl.Error("BadInput", func() { dsl.Attribute("message", dsl.String) }) + dsl.HTTP(func() { + dsl.POST("/add") + dsl.Response("BadInput", dsl.StatusBadRequest) + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + endpoint := plans[0].services.Get("Calc").Endpoints[0] + require.NotNil(t, endpoint.Payload.Request.PayloadInit.Declaration) + require.Equal(t, endpoint.Payload.Request.PayloadInit.Declaration.Name(), endpoint.Payload.Request.PayloadInit.Name) + require.NotNil(t, endpoint.Result.Responses[0].ResultInit.Declaration) + require.Equal(t, endpoint.Result.Responses[0].ResultInit.Declaration.Name(), endpoint.Result.Responses[0].ResultInit.Name) + require.NotNil(t, endpoint.Errors[0].Errors[0].Response.ResultInit.Declaration) + require.Equal(t, endpoint.Errors[0].Errors[0].Response.ResultInit.Declaration.Name(), endpoint.Errors[0].Errors[0].Response.ResultInit.Name) +} + +// TestHTTPTypeAndConstructorNamesShareOnePackage checks a body type and a +// payload constructor that request the same spelling. Goa must give them +// different names, and generated definitions and calls must use those names. +func TestHTTPTypeAndConstructorNamesShareOnePackage(t *testing.T) { + root := expr.RunDSL(t, func() { + payload := dsl.Type("NewAddPayload", func() { + dsl.Attribute("value", dsl.Int) + }) + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(payload) + dsl.HTTP(func() { dsl.POST("/add") }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + request := plans[0].services.Get("Calc").Endpoints[0].Payload.Request + require.NotEqual(t, request.ServerBody.Name, request.PayloadInit.Name) + definitions := renderedFiles(t, plans[0].ServerTypeFiles()) + calls := renderedFiles(t, plans[0].ServerFiles()) + require.Contains(t, definitions, "type "+request.ServerBody.Name+" ") + require.Contains(t, definitions, "func "+request.PayloadInit.Name+"(") + require.Contains(t, calls, request.PayloadInit.Name+"(") +} + +// TestNewPlansAssignsNamesAcrossRoots checks two designs whose service names +// resolve to the same generated directory. NewPlans must submit both sets of +// function names together so definitions and calls remain distinct. +func TestNewPlansAssignsNamesAcrossRoots(t *testing.T) { + makeRoot := func(apiName string) *expr.RootExpr { + return expr.RunDSL(t, func() { + dsl.API(apiName, func() {}) + dsl.Service("Shared", func() { + dsl.Method("Add", func() { + dsl.Payload(func() { dsl.Attribute("value", dsl.Int) }) + dsl.HTTP(func() { dsl.POST("/add") }) + }) + }) + }) + } + first := makeRoot("First") + second := makeRoot("Second") + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) + require.NoError(t, err) + servicePlans, err := service.NewPlans(generation, + service.PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + service.PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.NoError(t, err) + plans, err := NewPlans(generation, + PlanInput{Root: first, Service: servicePlans[0]}, + PlanInput{Root: second, Service: servicePlans[1]}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for index := range plans { + require.NoError(t, servicePlans[index].Link()) + require.NoError(t, plans[index].Link()) + } + + firstService := plans[0].services.Get("Shared") + secondService := plans[1].services.Get("Shared") + firstInit := firstService.Endpoints[0].Payload.Request.PayloadInit + secondInit := secondService.Endpoints[0].Payload.Request.PayloadInit + require.NotEqual(t, firstInit.Name, secondInit.Name) + require.Equal(t, plans[0].ServerTypeFiles()[0].Path, plans[1].ServerTypeFiles()[0].Path) + for index, init := range []*InitData{firstInit, secondInit} { + definitions := renderedFiles(t, plans[index].ServerTypeFiles()) + calls := renderedFiles(t, plans[index].ServerFiles()) + require.Contains(t, definitions, "func "+init.Name+"(") + require.Contains(t, calls, init.Name+"(") + } +} + +// TestHTTPHelperDefinitionsUseAssignedNames checks two designs that write the +// same server package. File helpers and mixed-result stream helpers must define +// the same names that their call sites use. +func TestHTTPHelperDefinitionsUseAssignedNames(t *testing.T) { + makeRoot := func(serviceName, typePrefix string) *expr.RootExpr { + return expr.RunDSL(t, func() { + payload := dsl.Type(typePrefix+"Payload", func() { + dsl.Attribute("value", dsl.String) + }) + result := dsl.Type(typePrefix+"Result", func() { + dsl.Attribute("value", dsl.String) + }) + event := dsl.Type(typePrefix+"Event", func() { + dsl.Attribute("value", dsl.String) + }) + dsl.Service(serviceName, func() { + dsl.Method("Create", func() { + dsl.Payload(payload) + dsl.Result(result) + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.POST("/create") + dsl.ServerSentEvents() + }) + }) + dsl.Files("/asset.json", "/embedded/file.json") + }) + }) + } + first := makeRoot("Foo Bar", "First") + second := makeRoot("Foo-Bar", "Second") + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) + require.NoError(t, err) + servicePlans, err := service.NewPlans(generation, + service.PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + service.PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.NoError(t, err) + plans, err := NewPlans(generation, + PlanInput{Root: first, Service: servicePlans[0]}, + PlanInput{Root: second, Service: servicePlans[1]}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for index := range plans { + require.NoError(t, servicePlans[index].Link()) + require.NoError(t, plans[index].Link()) + } + + for index, name := range []string{"Foo Bar", "Foo-Bar"} { + data := plans[index].services.Get(name) + endpoint := data.Endpoints[0] + code := renderedFiles(t, plans[index].ServerFiles()) + require.Contains(t, code, "type "+endpoint.DiscardStreamDeclaration.Name()+" struct{}") + require.Contains(t, code, "type "+data.AppendFSDeclaration.Name()+" struct {") + require.Contains(t, code, "func "+data.AppendPrefixDeclaration.Name()+"(") + require.Contains(t, code, "return "+data.AppendFSDeclaration.Name()+"{") + } +} + +// TestNewPlansRejectsDifferentServiceRoot checks the pairing before HTTP +// planning changes any generated package. The valid retry proves the rejected +// call did not reserve imports or names for the wrong design. +func TestNewPlansRejectsDifferentServiceRoot(t *testing.T) { + first := expr.RunDSL(t, func() { + dsl.Service("First", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/first") }) }) + }) + }) + second := expr.RunDSL(t, func() { + dsl.Service("Second", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/second") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) + require.NoError(t, err) + plans, err := service.NewPlans(generation, + service.PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + service.PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.NoError(t, err) + _, err = NewPlans(generation, + PlanInput{Root: first, Service: plans[1]}, + PlanInput{Root: second, Service: plans[0]}, + ) + require.EqualError(t, err, "HTTP root does not match its service plan root") + + _, err = NewPlans(generation, + PlanInput{Root: first, Service: plans[0]}, + PlanInput{Root: second, Service: plans[1]}, + ) + require.NoError(t, err) +} + +// TestPlanRequiresLinkedServicePlan proves HTTP generation cannot read service +// names before Goa has assigned every package name. +func TestPlanRequiresLinkedServicePlan(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { dsl.HTTP(func() { dsl.POST("/add") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.PanicsWithValue(t, "service render model requested before plan linking", func() { + _ = plans[0].Link() + }) +} + +// assertIndependentCodecFile changes every file field that the JSON-RPC +// generator edits, then checks that both the saved file and a new copy keep +// their original values. +func assertIndependentCodecFile(t *testing.T, saved *codegen.File, copyFile func() *codegen.File) { + t.Helper() + require.NotNil(t, saved) + require.NotEmpty(t, saved.SectionTemplates) + + originalPath := saved.Path + originalName := saved.SectionTemplates[0].Name + originalSource := saved.SectionTemplates[0].Source + originalImports := append([]*codegen.ImportSpec(nil), saved.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)...) + + changed := copyFile() + changed.Path = "changed.go" + changed.SectionTemplates[0].Name = "changed" + changed.SectionTemplates[0].Source = "changed" + changed.SectionTemplates[0].FuncMap = map[string]any{"changed": true} + codegen.AddImport(changed.SectionTemplates[0], &codegen.ImportSpec{Path: "changed.example/package"}) + var changedEndpoint bool + for _, section := range changed.SectionTemplates { + if endpoint := codecEndpointData(section.Data); endpoint != nil { + endpoint.Method.Name = "Changed" + changedEndpoint = true + break + } + } + require.True(t, changedEndpoint) + + fresh := copyFile() + require.Equal(t, originalPath, saved.Path) + require.Equal(t, originalPath, fresh.Path) + require.Equal(t, originalName, saved.SectionTemplates[0].Name) + require.Equal(t, originalName, fresh.SectionTemplates[0].Name) + require.Equal(t, originalSource, saved.SectionTemplates[0].Source) + require.Equal(t, originalSource, fresh.SectionTemplates[0].Source) + require.Equal(t, originalImports, saved.SectionTemplates[0].Data.(map[string]any)["Imports"]) + require.Equal(t, originalImports, fresh.SectionTemplates[0].Data.(map[string]any)["Imports"]) + for _, section := range fresh.SectionTemplates { + if endpoint := codecEndpointData(section.Data); endpoint != nil { + require.Equal(t, "Add", endpoint.Method.Name) + return + } + } + require.Fail(t, "copied codec file has no endpoint section") +} + +// codecEndpointData returns the copied endpoint value used by one encoder or +// decoder section. +func codecEndpointData(data any) *JSONRPCEndpointSnapshot { + switch actual := data.(type) { + case *JSONRPCEndpointSnapshot: + return actual + case *jsonRPCRequestCodecData: + return actual.JSONRPCEndpointSnapshot + default: + return nil + } +} diff --git a/http/codegen/plan_test_helpers_test.go b/http/codegen/plan_test_helpers_test.go new file mode 100644 index 0000000000..6ba6bc6422 --- /dev/null +++ b/http/codegen/plan_test_helpers_test.go @@ -0,0 +1,51 @@ +// This file prepares HTTP plans for tests through the same name assignment and +// file-building steps used by the generator. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// linkedHTTPPlanForRoot builds the HTTP files for root after every generated +// package has received its final names. +func linkedHTTPPlanForRoot(t *testing.T, root *expr.RootExpr) *Plan { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + return plans[0] +} + +// linkedHTTPExamplePlanForRoot builds an HTTP plan whose copied server data +// belongs to the same service plan. +func linkedHTTPExamplePlanForRoot(t *testing.T, root *expr.RootExpr) *ExamplePlan { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + examples, err := NewExamplePlan(plans[0], examplePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + return examples +} diff --git a/http/codegen/planned_name_collision_test.go b/http/codegen/planned_name_collision_test.go new file mode 100644 index 0000000000..1ce7a32e6b --- /dev/null +++ b/http/codegen/planned_name_collision_test.go @@ -0,0 +1,294 @@ +// This file proves generated HTTP definitions and their callers use the same +// package names after another generator claims the preferred spelling. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/codegentest" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestHTTPPlannedNamesSurvivePackageCollisions(t *testing.T) { + root := expr.RunDSL(t, func() { + child := dsl.Type("ChildPayload", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Pattern("value") + }) + dsl.Required("value") + }) + dsl.Service("Names", func() { + dsl.Method("Complete", func() { + dsl.Payload(func() { + dsl.Attribute("child", child) + dsl.Required("child") + }) + dsl.HTTP(func() { + dsl.POST("/complete") + }) + }) + dsl.Method("Socket", func() { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/socket") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + clientPackage, err := generation.ClaimPackage("generated.local/gen/http/names/client") + require.NoError(t, err) + serverPackage, err := generation.ClaimPackage("generated.local/gen/http/names/server") + require.NoError(t, err) + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameFunction, "BuildCompleteRequest"), + } { + require.NoError(t, clientPackage.DeclareName(declaration)) + } + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameType, "ChildPayloadRequestBody"), + codegen.NewExactName(codegen.NameFunction, "ValidateChildPayloadRequestBody"), + codegen.NewExactName(codegen.NameFunction, "validateChildPayloadRequestBody"), + codegen.NewExactName(codegen.NameType, "SocketServerStream"), + } { + require.NoError(t, serverPackage.DeclareName(declaration)) + } + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + serviceData := plans[0].services.Get("Names") + complete := serviceData.Endpoint("Complete") + require.Equal(t, "BuildCompleteRequest2", complete.RequestInit.Declaration.Name()) + require.Equal(t, complete.RequestInit.Declaration.Name(), complete.RequestInit.Name) + childData := releasedTypeData(t, serviceData, func(data *TypeData) bool { + return data.Declaration != nil && strings.HasPrefix(data.Declaration.Name(), "ChildPayload") + }) + require.Equal(t, "ChildPayloadRequestBody2", childData.Declaration.Name()) + require.Equal(t, "ValidateChildPayloadRequestBody2", childData.ValidatorDeclaration.Name()) + require.Equal(t, "validateChildPayloadRequestBody2", childData.NestedValidatorDeclaration.Name()) + require.Equal(t, childData.Declaration.Name(), childData.VarName) + require.Equal(t, childData.ValidatorDeclaration.Name(), childData.ValidatorName) + require.Equal(t, childData.NestedValidatorDeclaration.Name(), childData.NestedValidatorName) + socket := serviceData.Endpoint("Socket").ServerWebSocket + require.Equal(t, "SocketServerStream2", socket.VarDeclaration.Name()) + require.Equal(t, socket.VarDeclaration.Name(), socket.VarName) + + var source strings.Builder + for _, selection := range []struct { + files []*codegen.File + file string + section string + match func(any) bool + }{ + {plans[0].ClientFiles(), "encode_decode.go", "request-builder", func(data any) bool { + endpoint, ok := data.(*EndpointData) + return ok && endpoint.Method.Name == "Complete" + }}, + {plans[0].ClientFiles(), "client.go", "client-endpoint-init", func(data any) bool { + endpoint, ok := data.(*EndpointData) + return ok && endpoint.Method.Name == "Complete" + }}, + {plans[0].ServerTypeFiles(), "types.go", "server-body-attributes", func(data any) bool { + body, ok := data.(*TypeData) + return ok && body.Declaration == childData.Declaration + }}, + {plans[0].ServerTypeFiles(), "types.go", "server-validate", func(data any) bool { + body, ok := data.(*TypeData) + return ok && body.Declaration == complete.Payload.Request.ServerBody.Declaration + }}, + {plans[0].ServerTypeFiles(), "types.go", "server-validate", func(data any) bool { + body, ok := data.(*TypeData) + return ok && body.Declaration == childData.Declaration + }}, + {plans[0].ServerFiles(), "encode_decode.go", "request-decoder", func(data any) bool { + endpoint, ok := data.(*EndpointData) + return ok && endpoint.Method.Name == "Complete" + }}, + {plans[0].ServerFiles(), "websocket.go", "server-websocket-struct-type", func(data any) bool { + stream, ok := data.(*WebSocketData) + return ok && stream.VarDeclaration == socket.VarDeclaration + }}, + {plans[0].ServerFiles(), "server.go", "server-handler-init", func(data any) bool { + endpoint, ok := data.(*EndpointData) + return ok && endpoint.Method.Name == "Socket" + }}, + } { + sections := codegentest.Sections(selection.files, selection.file, selection.section) + matched := false + for _, section := range sections { + if selection.match(section.Data) { + source.WriteString(codegen.SectionCode(t, section)) + source.WriteString("\n") + matched = true + break + } + } + require.True(t, matched, "missing %s section in %s", selection.section, selection.file) + } + testutil.AssertGo(t, "testdata/golden/planned_name_collisions.go.golden", strings.TrimSpace(source.String())+"\n") +} + +// TestHTTPUnionPlannedNamesSurvivePackageCollisions checks that a union's type, +// kind, constants, constructors, and every use share the names selected by the +// generated package. +func TestHTTPUnionPlannedNamesSurvivePackageCollisions(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Names", func() { + dsl.Method("Choose", func() { + dsl.Payload(func() { + dsl.OneOf("choice", func() { + dsl.Attribute("text", dsl.String) + dsl.Attribute("count", dsl.Int) + }) + dsl.Required("choice") + }) + dsl.HTTP(func() { + dsl.POST("/choose") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + serverPackage, err := generation.ClaimPackage("generated.local/gen/http/names/server") + require.NoError(t, err) + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameType, "Choice"), + codegen.NewExactName(codegen.NameType, "ChoiceKind"), + codegen.NewExactName(codegen.NameConstant, "ChoiceKindText"), + codegen.NewExactName(codegen.NameConstant, "ChoiceKindCount"), + codegen.NewExactName(codegen.NameFunction, "NewChoiceText"), + codegen.NewExactName(codegen.NameFunction, "NewChoiceCount"), + } { + require.NoError(t, serverPackage.DeclareName(declaration)) + } + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + unions := plans[0].services.Get("Names").serverWireTypes.unionTypes() + require.Len(t, unions, 1) + union := unions[0] + require.NotEqual(t, "Choice", union.TypeDeclaration.Name()) + require.NotEqual(t, "ChoiceKind", union.KindDeclaration.Name()) + for _, field := range union.Fields { + require.NotEqual(t, "ChoiceKind"+codegen.Goify(field.Name, true), field.KindDeclaration.Name()) + require.NotEqual(t, "NewChoice"+codegen.Goify(field.Name, true), field.ConstructorDeclaration.Name()) + } + sections := codegentest.Sections(plans[0].ServerTypeFiles(), "types.go", "server-union-type") + require.Len(t, sections, 1) + testutil.AssertGo(t, "testdata/golden/planned_union_name_collisions.go.golden", codegen.SectionCode(t, sections[0])) +} + +// TestJSONRPCValidatorPlannedNamesSurvivePackageCollisions checks that the +// JSON-RPC body validator definition and decoder call use the same planned +// declaration after the preferred names are already taken. +func TestJSONRPCValidatorPlannedNamesSurvivePackageCollisions(t *testing.T) { + root := expr.RunDSL(t, func() { + payload := dsl.Type("ChoosePayload", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.MinLength(2) + }) + dsl.Required("value") + }) + dsl.Service("Names", func() { + dsl.Method("Choose", func() { + dsl.Payload(payload) + dsl.JSONRPC(func() { + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + serverPackage, err := generation.ClaimPackage("generated.local/gen/jsonrpc/names/server") + require.NoError(t, err) + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameType, "ChooseRequestBody"), + codegen.NewExactName(codegen.NameFunction, "ValidateChooseRequestBody"), + codegen.NewExactName(codegen.NameFunction, "DecodeChooseRequest"), + } { + require.NoError(t, serverPackage.DeclareName(declaration)) + } + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + serviceData := plans[0].services.Get("Names") + body := serviceData.Endpoint("Choose").Payload.Request.ServerBody + require.NotEqual(t, "ChooseRequestBody", body.Declaration.Name()) + require.NotEqual(t, "ValidateChooseRequestBody", body.ValidatorDeclaration.Name()) + snapshot, ok := plans[0].JSONRPCService("Names") + require.True(t, ok) + require.NotEqual(t, "DecodeChooseRequest", snapshot.Endpoints[0].RequestDecoderDeclaration.Name()) + + var source strings.Builder + for _, selection := range []struct { + files []*codegen.File + file string + section string + match func(any) bool + }{ + {plans[0].ServerTypeFiles(), "types.go", "request-body-type-decl", func(data any) bool { + candidate, ok := data.(*TypeData) + return ok && candidate.Declaration == body.Declaration + }}, + {plans[0].ServerTypeFiles(), "types.go", "server-validate", func(data any) bool { + candidate, ok := data.(*TypeData) + return ok && candidate.Declaration == body.Declaration + }}, + {[]*codegen.File{snapshot.ServerCodecFile()}, "encode_decode.go", "request-decoder", func(data any) bool { + endpoint := plannedJSONRPCEndpoint(data) + return endpoint != nil && endpoint.Method.Name == "Choose" + }}, + } { + sections := codegentest.Sections(selection.files, selection.file, selection.section) + matched := false + for _, section := range sections { + if selection.match(section.Data) { + source.WriteString(codegen.SectionCode(t, section)) + source.WriteString("\n") + matched = true + break + } + } + require.True(t, matched, "missing %s section in %s", selection.section, selection.file) + } + testutil.AssertGo(t, "testdata/golden/planned_jsonrpc_validator_collisions.go.golden", strings.TrimSpace(source.String())+"\n") +} + +// plannedJSONRPCEndpoint returns the copied endpoint stored in a JSON-RPC +// request codec section. +func plannedJSONRPCEndpoint(data any) *JSONRPCEndpointSnapshot { + switch actual := data.(type) { + case *jsonRPCRequestCodecData: + return actual.JSONRPCEndpointSnapshot + case *JSONRPCEndpointSnapshot: + return actual + default: + return nil + } +} diff --git a/http/codegen/planned_service_name_uses_test.go b/http/codegen/planned_service_name_uses_test.go new file mode 100644 index 0000000000..bc3dcf207b --- /dev/null +++ b/http/codegen/planned_service_name_uses_test.go @@ -0,0 +1,208 @@ +// This file verifies that service declarations and transport callers keep the +// same Go names when authored types claim the usual generated spellings. +package codegen + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/codegentest" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + gencodegengrpc "goa.design/goa/v3/grpc/codegen" +) + +type ( + // plannedNameSection identifies one complete generated section or one file + // body included in the cross-transport golden output. + plannedNameSection struct { + label string + files []*codegen.File + file string + name string + whole bool + } +) + +// TestPlannedServiceNamesUsedAcrossTransports renders each definition and use +// from the same generation so a changed or rebuilt name breaks one fixture. +func TestPlannedServiceNamesUsedAcrossTransports(t *testing.T) { + root := expr.RunDSL(t, plannedServiceNameUsesDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + grpcPlans, err := gencodegengrpc.NewPlans(generation, gencodegengrpc.PlanInput{ + Root: root, + Service: servicePlan, + }) + require.NoError(t, err) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + httpExamples, err := NewExamplePlan(httpPlans[0], examplePlan) + require.NoError(t, err) + grpcExamples, err := gencodegengrpc.NewExamplePlan(grpcPlans[0], examplePlan) + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, grpcPlans[0].Link()) + + serviceData := servicePlan.Services().Get("Collisions") + require.NotNil(t, serviceData) + method := serviceData.Methods[0] + require.Equal(t, "Endpoints2", serviceData.EndpointsDeclaration.Name()) + require.Equal(t, "MethodNames2", serviceData.MethodNamesDeclaration.Name()) + require.Equal(t, "ClientInterceptors2", serviceData.ClientInterceptorsDeclaration.Name()) + require.Equal(t, "WrapReadClientEndpoint2", method.ClientEndpointWrapperDeclaration.Name()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + sections := []plannedNameSection{ + { + label: "service method names definition", + files: serviceFiles, + file: "service.go", + name: "service", + }, + { + label: "service endpoints definition", + files: serviceFiles, + file: "endpoints.go", + name: "endpoints-struct", + }, + { + label: "client interceptors definition", + files: serviceFiles, + file: "client_interceptors.go", + name: "client-interceptors-type", + }, + { + label: "client endpoint wrapper definition", + files: serviceFiles, + file: "client_interceptors.go", + name: "client-wrapper", + }, + { + label: "HTTP server endpoints use", + files: httpPlans[0].ServerFiles(), + file: "server.go", + name: "server-init", + }, + { + label: "HTTP server method names use", + files: httpPlans[0].ServerFiles(), + file: "server.go", + name: "server-method-names", + }, + { + label: "HTTP command parser", + files: httpPlans[0].ClientCLIFiles(), + file: "cli.go", + name: "parse-endpoint", + }, + { + label: "HTTP example client interceptor use", + files: httpExamples.CLIFiles(), + file: "http.go", + whole: true, + }, + { + label: "gRPC server endpoints use", + files: grpcPlans[0].ServerFiles(), + file: "server.go", + name: "server-init", + }, + { + label: "gRPC example server endpoints use", + files: grpcExamples.ServerFiles(), + file: "grpc.go", + whole: true, + }, + { + label: "gRPC command parser", + files: grpcPlans[0].ClientCLIFiles(), + file: "cli.go", + name: "parse-endpoint-grpc", + }, + } + + var source strings.Builder + for _, section := range sections { + source.WriteString("===== ") + source.WriteString(section.label) + source.WriteString(" =====\n") + source.WriteString(plannedNameSectionCode(t, section)) + source.WriteString("\n") + } + testutil.AssertString(t, "testdata/golden/planned_service_name_uses.go.golden", source.String()) +} + +// plannedNameSectionCode renders either one complete section or the complete +// body of a file whose opening and closing statements span several sections. +func plannedNameSectionCode(t *testing.T, section plannedNameSection) string { + t.Helper() + if !section.whole { + matches := codegentest.Sections(section.files, section.file, section.name) + require.Len(t, matches, 1, section.label) + return codegen.SectionCode(t, matches[0]) + } + for _, file := range section.files { + if filepath.Base(file.Path) == section.file { + var source bytes.Buffer + for _, part := range file.SectionTemplates[1:] { + require.NoError(t, part.Write(&source)) + } + return codegen.FormatTestCode(t, "package foo\n"+source.String()) + } + } + require.Fail(t, "missing generated file", section.label) + return "" +} + +// plannedServiceNameUsesDSL makes authored types claim four names normally +// chosen for the generated service and interceptor code. +func plannedServiceNameUsesDSL() { + endpointType := dsl.Type("Endpoints", dsl.String) + methodNamesType := dsl.Type("MethodNames", dsl.String) + interceptorsType := dsl.Type("ClientInterceptors", dsl.String) + wrapperType := dsl.Type("WrapReadClientEndpoint", dsl.String) + trace := dsl.Interceptor("Trace") + + dsl.API("Name Test", func() { + dsl.Server("test", func() { + dsl.Host("development", func() { + dsl.URI("http://localhost:80") + }) + }) + }) + dsl.Service("Collisions", func() { + dsl.ClientInterceptor(trace) + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Field(1, "endpoint", endpointType) + dsl.Field(2, "method_names", methodNamesType) + dsl.Field(3, "interceptors", interceptorsType) + dsl.Field(4, "wrapper", wrapperType) + }) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/read") + }) + dsl.GRPC(func() { + }) + }) + }) +} diff --git a/http/codegen/plugin_api_compatibility_test.go b/http/codegen/plugin_api_compatibility_test.go new file mode 100644 index 0000000000..42a6762fb7 --- /dev/null +++ b/http/codegen/plugin_api_compatibility_test.go @@ -0,0 +1,183 @@ +// This file checks the public HTTP plugin fields kept for existing plugins. +package codegen + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +var ( + _ func(string, *ServicesData) []*codegen.File = ClientFiles + _ func(string, *ServicesData) []*codegen.File = ClientCLIFiles + _ func(string, *ServicesData) []*codegen.File = ServerFiles + _ func(string, *ServicesData) []*codegen.File = ServerTypeFiles + _ func(string, *ServicesData) []*codegen.File = ClientTypeFiles + _ func(*ServicesData) []*codegen.File = PathFiles + _ func(string, *expr.HTTPServiceExpr, *ServicesData) *codegen.File = ClientEncodeDecodeFile + _ func(string, *expr.HTTPServiceExpr, *ServicesData) *codegen.File = ServerEncodeDecodeFile + _ func(string, *expr.HTTPServiceExpr, *ServicesData) *codegen.File = WebsocketClientFile +) + +// TestReleasedHTTPNamesMatchDeclarations checks all 23 released string fields, +// including optional fields that are empty when no declaration exists. +func TestReleasedHTTPNamesMatchDeclarations(t *testing.T) { + plan := linkedHTTPPlanForRoot(t, releasedHTTPNamesRoot(t)) + service := plan.services.Get("Names") + assertReleasedName(t, service.ServerStruct, service.ServerStructDeclaration) + assertReleasedName(t, service.MountPointStruct, service.MountPointStructDeclaration) + assertReleasedName(t, service.ServerInit, service.ServerInitDeclaration) + assertReleasedName(t, service.MountServer, service.MountServerDeclaration) + assertReleasedName(t, service.ClientStruct, service.ClientStructDeclaration) + + for _, endpoint := range service.Endpoints { + assertReleasedName(t, endpoint.MountHandler, endpoint.MountHandlerDeclaration) + assertReleasedName(t, endpoint.HandlerInit, endpoint.HandlerInitDeclaration) + assertReleasedName(t, endpoint.RequestDecoder, endpoint.RequestDecoderDeclaration) + assertReleasedName(t, endpoint.ResponseEncoder, endpoint.ResponseEncoderDeclaration) + assertReleasedName(t, endpoint.ErrorEncoder, endpoint.ErrorEncoderDeclaration) + assertReleasedName(t, endpoint.ClientStruct, endpoint.ClientStructDeclaration) + assertReleasedName(t, endpoint.RequestEncoder, endpoint.RequestEncoderDeclaration) + assertReleasedName(t, endpoint.ResponseDecoder, endpoint.ResponseDecoderDeclaration) + assertReleasedName(t, endpoint.BuildStreamPayload, endpoint.BuildStreamPayloadDeclaration) + } + + multipart := service.Endpoint("Multipart") + for _, data := range []*MultipartData{multipart.MultipartRequestDecoder, multipart.MultipartRequestEncoder} { + require.NotNil(t, data) + assertReleasedName(t, data.FuncName, data.FuncDeclaration) + assertReleasedName(t, data.InitName, data.InitDeclaration) + } + stream := service.Endpoint("Watch").SSE + require.NotNil(t, stream) + assertReleasedName(t, stream.StructName, stream.StructDeclaration) + require.NotEmpty(t, service.FileServers) + assertReleasedName(t, service.FileServers[0].MountHandler, service.FileServers[0].MountHandlerDeclaration) + empty := service.Endpoint("Empty") + require.Nil(t, empty.RequestDecoderDeclaration) + require.Empty(t, empty.RequestDecoder) + socket := service.Endpoint("Socket").ServerWebSocket + require.NotNil(t, socket) + assertReleasedName(t, socket.VarName, socket.VarDeclaration) + assertReleasedName(t, service.Endpoint("Complete").RequestInit.Name, service.Endpoint("Complete").RequestInit.Declaration) + typeData := releasedTypeData(t, service, func(data *TypeData) bool { return data.NestedValidatorDeclaration != nil }) + assertReleasedName(t, typeData.VarName, typeData.Declaration) + assertReleasedName(t, typeData.ValidatorName, typeData.ValidatorDeclaration) + assertReleasedName(t, typeData.NestedValidatorName, typeData.NestedValidatorDeclaration) +} + +// TestReleasedHTTPFileFunctionsUsePlannedPackage checks that public helpers +// render the retained HTTP plan and reject a different generated package. +func TestReleasedHTTPFileFunctionsUsePlannedPackage(t *testing.T) { + plan := linkedHTTPPlanForRoot(t, expr.RunDSL(t, testdata.MultiSimpleDSL)) + genpkg := plan.services.GenPkg() + for _, files := range []struct { + name string + released func(string, *ServicesData) []*codegen.File + planned func(*ServicesData) []*codegen.File + }{ + {name: "client", released: ClientFiles, planned: clientFiles}, + {name: "client CLI", released: ClientCLIFiles, planned: clientCLIFiles}, + {name: "server", released: ServerFiles, planned: serverFiles}, + {name: "server types", released: ServerTypeFiles, planned: serverTypeFiles}, + {name: "client types", released: ClientTypeFiles, planned: clientTypeFiles}, + } { + t.Run(files.name, func(t *testing.T) { + require.Len(t, files.released(genpkg, plan.services), len(files.planned(plan.services))) + require.PanicsWithValue( + t, + `HTTP generation package "other.local/gen" does not match planned package "generated.local/gen"`, + func() { + files.released("other.local/gen", plan.services) + }, + ) + }) + } + require.Len(t, PathFiles(plan.services), len(pathFiles(plan.services))) + service := plan.root.API.HTTP.Services[0] + require.Equal(t, clientEncodeDecodeFile(service, plan.services).Path, ClientEncodeDecodeFile(genpkg, service, plan.services).Path) + require.Equal(t, serverEncodeDecodeFile(service, plan.services).Path, ServerEncodeDecodeFile(genpkg, service, plan.services).Path) + + websocket := linkedHTTPPlanForRoot(t, releasedHTTPNamesRoot(t)) + websocketService := websocket.root.API.HTTP.Service("Names") + require.Equal( + t, + websocketClientFile(websocketService, websocket.services).Path, + WebsocketClientFile(websocket.services.GenPkg(), websocketService, websocket.services).Path, + ) +} + +// TestReleasedHTTPNameUsesCollisionSuffix checks that a compatibility field +// copies the final package name instead of rebuilding an unsuffixed name. +func TestReleasedHTTPNameUsesCollisionSuffix(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("read-data", func() { dsl.HTTP(func() { dsl.GET("/first") }) }) + dsl.Method("read_data", func() { dsl.HTTP(func() { dsl.GET("/second") }) }) + }) + }) + plan := linkedHTTPPlanForRoot(t, root) + endpoint := plan.services.Get("Calc").Endpoint("read_data") + require.NotEqual(t, "MountReadDataHandler", endpoint.MountHandlerDeclaration.Name()) + require.Contains(t, endpoint.MountHandlerDeclaration.Name(), "MountReadData") + require.Equal(t, endpoint.MountHandlerDeclaration.Name(), endpoint.MountHandler) +} + +// TestReleasedSSEDataFieldTypeMatchesPlannedValue verifies existing plugins +// can still read the final type of an explicitly mapped SSE data field. +func TestReleasedSSEDataFieldTypeMatchesPlannedValue(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Events", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(func() { + dsl.Attribute("value", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("value") + }) + }) + }) + }) + stream := linkedHTTPPlanForRoot(t, root).services.Get("Events").Endpoint("Watch").SSE + require.NotNil(t, stream) + require.NotEmpty(t, stream.DataField) + require.Equal(t, stream.Data.TypeRef, stream.DataFieldTypeRef) +} + +// TestReleasedHTTPNameFieldsRemainSourceCompatible checks old keyed literals +// and template reads still compile. +func TestReleasedHTTPNameFieldsRemainSourceCompatible(t *testing.T) { + data := struct { + Endpoint *EndpointData + Service *ServiceData + FileServer *FileServerData + Multipart *MultipartData + Stream *SSEData + WebSocket *WebSocketData + Init *InitData + Type *TypeData + }{ + &EndpointData{MountHandler: "MountReadHandler"}, + &ServiceData{ServerStruct: "Server"}, + &FileServerData{MountHandler: "MountAssetJSON"}, + &MultipartData{FuncName: "DecoderFunc", InitName: "NewDecoder"}, + &SSEData{StructName: "ReadServerStream"}, + &WebSocketData{VarName: "SocketServerStream"}, + &InitData{Name: "NewBody"}, + &TypeData{VarName: "Body", ValidatorName: "ValidateBody", NestedValidatorName: "validateBodyAt"}, + } + tmpl := template.Must(template.New("released-fields").Parse( + `{{.Endpoint.MountHandler}} {{.Service.ServerStruct}} {{.FileServer.MountHandler}} {{.Multipart.FuncName}} {{.Stream.StructName}} {{.WebSocket.VarName}} {{.Init.Name}} {{.Type.VarName}} {{.Type.ValidatorName}} {{.Type.NestedValidatorName}}`, + )) + var rendered bytes.Buffer + require.NoError(t, tmpl.Execute(&rendered, data)) + require.Equal(t, "MountReadHandler Server MountAssetJSON DecoderFunc ReadServerStream SocketServerStream NewBody Body ValidateBody validateBodyAt", rendered.String()) +} diff --git a/http/codegen/plugin_api_test_helpers_test.go b/http/codegen/plugin_api_test_helpers_test.go new file mode 100644 index 0000000000..3626963fe1 --- /dev/null +++ b/http/codegen/plugin_api_test_helpers_test.go @@ -0,0 +1,108 @@ +// This file builds one HTTP design that exercises every public name kept for +// existing plugins and provides small assertions shared by compatibility tests. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// releasedHTTPNamesRoot returns a service with ordinary, multipart, streaming, +// empty, and raw-body endpoints plus a file server. +func releasedHTTPNamesRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + child := dsl.Type("Child", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Pattern("value") + }) + dsl.Required("value") + }) + dsl.Service("Names", func() { + dsl.Method("Complete", func() { + dsl.Payload(func() { + dsl.Attribute("child", child) + dsl.Required("child") + }) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/complete") + }) + }) + dsl.Method("Multipart", func() { + dsl.Payload(dsl.String) + dsl.HTTP(func() { + dsl.POST("/multipart") + dsl.MultipartRequest() + }) + }) + dsl.Method("Watch", func() { + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + dsl.Method("Socket", func() { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/socket") + }) + }) + dsl.Method("Raw", func() { + dsl.HTTP(func() { + dsl.POST("/raw") + dsl.SkipRequestBodyEncodeDecode() + }) + }) + dsl.Method("Empty", func() { + dsl.HTTP(func() { + dsl.GET("/empty") + }) + }) + dsl.Files("/asset.json", "asset.json") + }) + }) +} + +// assertReleasedName checks that one public compatibility string contains the +// final name selected by its declaration. +func assertReleasedName(t *testing.T, name string, declaration *codegen.NameDeclaration) { + t.Helper() + if declaration == nil { + require.Empty(t, name) + return + } + require.Equal(t, declaration.Name(), name) +} + +// releasedTypeData returns the first planned HTTP body type accepted by match. +func releasedTypeData(t *testing.T, service *ServiceData, match func(*TypeData) bool) *TypeData { + t.Helper() + candidates := append([]*TypeData(nil), service.ServerBodyAttributeTypes...) + candidates = append(candidates, service.ClientBodyAttributeTypes...) + for _, endpoint := range service.Endpoints { + if endpoint.Payload != nil && endpoint.Payload.Request != nil { + candidates = append(candidates, endpoint.Payload.Request.ServerBody, endpoint.Payload.Request.ClientBody) + } + if endpoint.Result != nil { + for _, response := range endpoint.Result.Responses { + candidates = append(candidates, response.ServerBody...) + candidates = append(candidates, response.ClientBody) + } + } + } + for _, candidate := range candidates { + if candidate != nil && match(candidate) { + return candidate + } + } + require.FailNow(t, "planned HTTP body type was not found") + return nil +} diff --git a/http/codegen/released_streaming_name_test.go b/http/codegen/released_streaming_name_test.go new file mode 100644 index 0000000000..68c8cc7e6a --- /dev/null +++ b/http/codegen/released_streaming_name_test.go @@ -0,0 +1,33 @@ +// This file checks that WebSocket response collections keep the public Go +// names generated by released Goa versions. +package codegen + +import ( + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +// TestReleasedStreamingResponseCollectionNames catches renaming a response +// collection merely because it is sent after receiving streamed input. +func TestReleasedStreamingResponseCollectionNames(t *testing.T) { + root := expr.RunDSL(t, testdata.StreamingPayloadResultCollectionWithExplicitViewDSL) + plan := linkedHTTPPlanForRoot(t, root) + + t.Run("server", func(t *testing.T) { + file := plan.ServerTypeFiles()[0] + sections := append(file.Section("response-server-body"), file.Section("server-body-init")...) + code := codegen.SectionsCode(t, sections) + testutil.AssertGo(t, "testdata/golden/released_streaming_response_collection_server.go.golden", code) + }) + + t.Run("client", func(t *testing.T) { + file := plan.ClientTypeFiles()[0] + sections := append(file.Section("client-response-body"), file.Section("client-body-init")...) + code := codegen.SectionsCode(t, sections) + testutil.AssertGo(t, "testdata/golden/released_streaming_response_collection_client.go.golden", code) + }) +} diff --git a/http/codegen/server.go b/http/codegen/server.go index ec7e4976f6..b0f7d82628 100644 --- a/http/codegen/server.go +++ b/http/codegen/server.go @@ -1,3 +1,5 @@ +// This file renders HTTP server handlers and encoders per service; each file +// receives imports derived only from the endpoint sections it contains. package codegen import ( @@ -11,31 +13,43 @@ import ( "goa.design/goa/v3/expr" ) -// ServerFiles returns the generated HTTP server files. -func ServerFiles(genpkg string, data *ServicesData) []*codegen.File { +type ( + // appendFSData gives the server file its chosen file helper names and path + // replacements. + appendFSData struct { + *ServiceData + // Mappings pairs each requested path with the embedded file path opened for it. + Mappings map[string]string + } +) + +// serverFiles builds the HTTP server files read by Plan.Link. +func serverFiles(data *ServicesData) []*codegen.File { files := make([]*codegen.File, 0, len(data.Expressions.Services)*3) for _, svc := range data.Expressions.Services { - files = append(files, serverFile(genpkg, svc, data)) - if f := websocketServerFile(genpkg, svc, data); f != nil { - files = append(files, f) + files = append(files, addPlannedFileImports(serverFile(svc, data), data)) + if f := websocketServerFile(svc, data); f != nil { + files = append(files, addPlannedFileImports(f, data)) } - if f := sseServerFile(genpkg, svc, data); f != nil { - files = append(files, f) + if f := sseServerFile(svc, data); f != nil { + files = append(files, addPlannedFileImports(f, data)) } } for _, svc := range data.Expressions.Services { - if f := ServerEncodeDecodeFile(genpkg, svc, data); f != nil { - files = append(files, f) + if f := serverEncodeDecodeFile(svc, data); f != nil { + files = append(files, addPlannedFileImports(f, data)) } } return files } // serverFile returns the file implementing the HTTP server. -func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func serverFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName fpath := filepath.Join(codegen.Gendir, "http", svcName, "server", "server.go") + outputPackage := generatedFileOutputPackage(services, fpath) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s HTTP server", svc.Name()) funcs := map[string]any{ "join": strings.Join, @@ -60,8 +74,7 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, + services.ServiceImport(outputPackage, svc.Name()), } sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), @@ -107,7 +120,12 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData } } } - sections = append(sections, &codegen.SectionTemplate{Name: "append-fs", Source: httpTemplates.Read(appendFsT), FuncMap: funcs, Data: mappedFiles}) + sections = append(sections, &codegen.SectionTemplate{ + Name: "append-fs", + Source: httpTemplates.Read(appendFsT), + FuncMap: funcs, + Data: appendFSData{ServiceData: data, Mappings: mappedFiles}, + }) } for _, s := range data.FileServers { sections = append(sections, &codegen.SectionTemplate{Name: "server-files", Source: httpTemplates.Read(fileServerT), FuncMap: funcs, Data: s}) @@ -116,12 +134,14 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData return &codegen.File{Path: fpath, SectionTemplates: sections} } -// ServerEncodeDecodeFile returns the file defining the HTTP server encoding and +// serverEncodeDecodeFile returns the file defining the HTTP server encoding and // decoding logic. -func ServerEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func serverEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, services.dir(), svcName, "server", "encode_decode.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s %s server encoders and decoders", svc.Name(), services.label()) imports := []*codegen.ImportSpec{ {Path: "context"}, @@ -136,8 +156,10 @@ func ServerEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * {Path: "unicode/utf8"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, + services.ServiceImport(outputPackage, svc.Name()), + } + if serviceHasViewedResult(data, nil) { + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } sections := []*codegen.SectionTemplate{codegen.Header(title, "server", imports)} @@ -199,7 +221,7 @@ func ServerEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * func transTmplFuncs(s *expr.HTTPServiceExpr, services *ServicesData) map[string]any { return map[string]any{ "goTypeRef": func(dt expr.DataType) string { - return services.ServicesData.Get(s.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return services.Get(s.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, "isAliased": func(dt expr.DataType) bool { _, ok := dt.(expr.UserType) diff --git a/http/codegen/server_decode_test.go b/http/codegen/server_decode_test.go index b1ac5dd444..ee6d8bae66 100644 --- a/http/codegen/server_decode_test.go +++ b/http/codegen/server_decode_test.go @@ -175,6 +175,7 @@ func TestDecode(t *testing.T) { {"decode-body-primitive-bool-validate", testdata.PayloadBodyPrimitiveBoolValidateDSL}, {"decode-body-primitive-array-string-validate", testdata.PayloadBodyPrimitiveArrayStringValidateDSL}, {"decode-body-primitive-array-bool-validate", testdata.PayloadBodyPrimitiveArrayBoolValidateDSL}, + {"decode-body-required-primitive-arrays", testdata.RequiredPrimitiveArrayDSL}, {"decode-body-primitive-array-user-required", testdata.PayloadBodyPrimitiveArrayUserRequiredDSL}, {"decode-body-primitive-array-user-validate", testdata.PayloadBodyPrimitiveArrayUserValidateDSL}, @@ -203,8 +204,11 @@ func TestDecode(t *testing.T) { {"decode-map-query-object", testdata.PayloadMapQueryObjectDSL}, {"decode-multipart-body-primitive", testdata.PayloadMultipartPrimitiveDSL}, {"decode-multipart-body-user-type", testdata.PayloadMultipartUserTypeDSL}, + {"decode-multipart-body-validation", testdata.PayloadMultipartValidationDSL}, {"decode-multipart-body-array-type", testdata.PayloadMultipartArrayTypeDSL}, {"decode-multipart-body-map-type", testdata.PayloadMultipartMapTypeDSL}, + {"decode-multipart-with-param", testdata.PayloadMultipartWithParamDSL}, + {"decode-multipart-with-params-and-headers", testdata.PayloadMultipartWithParamsAndHeadersDSL}, {"decode-with-params-and-headers-dsl", testdata.WithParamsAndHeadersBlockDSL}, {"decode-query-int-alias", testdata.QueryIntAliasDSL}, @@ -226,8 +230,8 @@ func TestDecode(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) diff --git a/http/codegen/server_encode_test.go b/http/codegen/server_encode_test.go index 898ef907f1..aea0fb3383 100644 --- a/http/codegen/server_encode_test.go +++ b/http/codegen/server_encode_test.go @@ -92,8 +92,8 @@ func TestEncode(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) @@ -117,8 +117,8 @@ func TestEncodeMarshallingAndUnmarshalling(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates totalSectionsExpected := c.SectionsOffset + c.SectionCount diff --git a/http/codegen/server_error_encoder_test.go b/http/codegen/server_error_encoder_test.go index ccd7a216e6..b7ed8cac57 100644 --- a/http/codegen/server_error_encoder_test.go +++ b/http/codegen/server_error_encoder_test.go @@ -35,8 +35,8 @@ func TestEncodeError(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) diff --git a/http/codegen/server_extensions_test.go b/http/codegen/server_extensions_test.go new file mode 100644 index 0000000000..10dba84b01 --- /dev/null +++ b/http/codegen/server_extensions_test.go @@ -0,0 +1,74 @@ +// This file compares generated server source for plugin-declared handler +// wrappers and additional routes with reviewed golden files. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/codegentest" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestServerExtensions(t *testing.T) { + root := extensionRoot(t) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + serviceExpr := root.API.HTTP.Services[0] + _, err := plan.DeclareServerHandlerWrapper(serviceExpr, "First", extensionNameOrder("first")) + require.NoError(t, err) + _, err = plan.DeclareServerHandlerWrapper(serviceExpr, "Second", extensionNameOrder("second")) + require.NoError(t, err) + _, err = plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "wrapEndpoint", extensionNameOrder("endpoint")) + require.NoError(t, err) + _, err = plan.DeclareServerMount(serviceExpr, "MountPreflight", extensionNameOrder("mount"), []ServerMountPoint{ + {Method: "Preflight item", Verb: "OPTIONS", Pattern: "/items/{id}"}, + {Method: "Preflight assets", Verb: "OPTIONS", Pattern: "/assets/{*path}"}, + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + files := plan.ServerFiles() + for _, test := range []struct { + section string + sectionIndex int + golden string + }{ + {"server-mount", 0, "testdata/golden/server_extensions_mount.go.golden"}, + {"server-handler", 0, "testdata/golden/server_extensions_endpoint_helper.go.golden"}, + {"server-files", 0, "testdata/golden/server_extensions_file_helper.go.golden"}, + {"server-files", 1, "testdata/golden/server_extensions_redirect_helper.go.golden"}, + {"server-init", 0, "testdata/golden/server_extensions_init.go.golden"}, + } { + sections := codegentest.Sections(files, "server.go", test.section) + require.Greater(t, len(sections), test.sectionIndex) + testutil.AssertGo(t, test.golden, codegen.SectionCode(t, sections[test.sectionIndex])) + } +} + +func TestServerExtensionMountPointEscaping(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Escape", func() { + dsl.Method("Ping", func() { dsl.HTTP(func() { dsl.GET("/") }) }) + }) + }) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + _, err := plan.DeclareServerMount(root.API.HTTP.Services[0], "MountQuoted", extensionNameOrder("quoted"), []ServerMountPoint{{ + Method: "Quoted \"method\"\nnext", + Verb: "CUSTOM\\VERB", + Pattern: "/quoted/\"value\"\\next\nline", + }}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + sections := codegentest.Sections(plan.ServerFiles(), "server.go", "server-init") + require.Len(t, sections, 1) + testutil.AssertGo(t, "testdata/golden/server_extensions_escaping.go.golden", codegen.SectionCode(t, sections[0])) +} diff --git a/http/codegen/server_handler_test.go b/http/codegen/server_handler_test.go index 824d81c80d..23aa0f770a 100644 --- a/http/codegen/server_handler_test.go +++ b/http/codegen/server_handler_test.go @@ -14,7 +14,6 @@ import ( ) func TestServerHandler(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -26,8 +25,8 @@ func TestServerHandler(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() sections := codegentest.Sections(fs, "server.go", "server-handler") require.Greater(t, len(sections), 0) code := codegen.SectionCode(t, sections[0]) diff --git a/http/codegen/server_init_test.go b/http/codegen/server_init_test.go index f9bedc77a6..4bc152d230 100644 --- a/http/codegen/server_init_test.go +++ b/http/codegen/server_init_test.go @@ -13,7 +13,6 @@ import ( ) func TestServerInit(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -32,8 +31,8 @@ func TestServerInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, c.FileCount) sections := fs[0].SectionTemplates require.Greater(t, len(sections), c.SectionNum) diff --git a/http/codegen/server_mount_test.go b/http/codegen/server_mount_test.go index 5875b6ef18..4c02cd02ba 100644 --- a/http/codegen/server_mount_test.go +++ b/http/codegen/server_mount_test.go @@ -14,7 +14,6 @@ import ( ) func TestServerMount(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -34,8 +33,8 @@ func TestServerMount(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() sections := codegentest.Sections(fs, "server.go", c.SectionName) require.Greater(t, len(sections), c.SectionNum) code := codegen.SectionCode(t, sections[c.SectionNum]) diff --git a/http/codegen/server_payload_types_test.go b/http/codegen/server_payload_types_test.go index 0fd2ace2b3..fec42b0547 100644 --- a/http/codegen/server_payload_types_test.go +++ b/http/codegen/server_payload_types_test.go @@ -123,8 +123,8 @@ func TestPayloadConstructor(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) require.Len(t, root.API.HTTP.Services, 1) - services := CreateHTTPServices(root) - fs := typesFile("", root.API.HTTP.Services[0], true, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerTypeFiles()[0] sections := fs.SectionTemplates var section *codegen.SectionTemplate for _, s := range sections { diff --git a/http/codegen/server_types_test.go b/http/codegen/server_types_test.go index e572e03288..a892c68b19 100644 --- a/http/codegen/server_types_test.go +++ b/http/codegen/server_types_test.go @@ -14,7 +14,6 @@ import ( ) func TestServerTypes(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -36,13 +35,15 @@ func TestServerTypes(t *testing.T) { {"server-header-custom-name", testdata.PayloadHeaderCustomNameDSL}, {"server-cookie-custom-name", testdata.PayloadCookieCustomNameDSL}, {"server-payload-with-validated-alias", testdata.PayloadWithValidatedAliasDSL}, + {"server-required-primitive-arrays", testdata.RequiredPrimitiveArrayDSL}, + {"server-multipart-validation", testdata.PayloadMultipartValidationDSL}, {"server-streaming-payload-required-fields", testdata.StreamingPayloadRequiredFieldsDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], true, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerTypeFiles()[0] var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { require.NoError(t, s.Write(&buf)) diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index 32d034980a..06f81f8ef1 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -1,16 +1,19 @@ +// This file turns HTTP endpoint designs into the data used to write client, +// server, request, response, validation, and streaming code. package codegen import ( "bytes" "fmt" "net/http" + "path" "slices" "sort" - "strconv" "strings" "text/template" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) @@ -36,10 +39,28 @@ type ( *service.ServicesData Expressions *expr.HTTPExpr HTTPData map[string]*ServiceData - // jsonrpc indicates that the data describes the JSON-RPC - // transport: generated files live under gen/jsonrpc and titles - // use the JSON-RPC label. + // jsonrpc is true when files are written under gen/jsonrpc and their + // headings use the JSON-RPC name. jsonrpc bool + // viewedResultConstructors contains every client result function name + // chosen for the generated client package. + viewedResultConstructors map[viewedConstructorKey]*codegen.NameDeclaration + payloadConstructors map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + streamConstructors map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + errorConstructors map[*expr.HTTPErrorExpr]*codegen.NameDeclaration + // plannedWireTypes contains each copied request and response field with + // the Go name used by both its definition and its references. + plannedWireTypes map[*expr.HTTPServiceExpr]*plannedWireTypes + // plannedSymbols contains the Go names used in each client and server package. + plannedSymbols map[*expr.HTTPServiceExpr]*httpSymbols + // cliParsers contains the function names for each command parser file. + cliParsers map[string]*cli.ParserPlan + // linkErr is the first conversion error found while building template data. + // Plan.Link returns it before exposing any generated files. + linkErr error + // fileImports contains the exact design-derived imports collected for each + // generated file before package names were frozen. + fileImports map[string][]*codegen.ImportSpec } // ServiceData contains the data used to render the code related to a @@ -47,23 +68,66 @@ type ( ServiceData struct { // Service contains the related service data. Service *service.Data + // ClientPkgName is the Go package name written before client types. + ClientPkgName string + // ServerPkgName is the Go package name written before server types. + ServerPkgName string // Endpoints describes the endpoint data for this service. Endpoints []*EndpointData // FileServers lists the file servers for this service. FileServers []*FileServerData - // ServerStruct is the name of the HTTP server struct. + // ServerHandlerWrappers lists the planned wrapper declarations copied into + // every endpoint and file mount helper for this service. + ServerHandlerWrappers []*codegen.NameDeclaration + // ServerMounts lists functions that add routes after the routes defined in + // the design. + ServerMounts []*ServerMount + // ServerStruct is the server type name kept for existing plugins. + // + // Deprecated: Use ServerStructDeclaration.Name() after planning so name collisions are handled. ServerStruct string - // MountPointStruct is the name of the mount point struct. + // ServerStructDeclaration is the generated Go type name used by server definitions and calls. + ServerStructDeclaration *codegen.NameDeclaration + // MountPointStruct is the mount point type name kept for existing plugins. + // + // Deprecated: Use MountPointStructDeclaration.Name() after planning so name collisions are handled. MountPointStruct string - // ServerInit is the name of the constructor of the server - // struct. + // MountPointStructDeclaration is the generated Go type name used by the mount point type. + MountPointStructDeclaration *codegen.NameDeclaration + // ServerInit is the server constructor name kept for existing plugins. + // + // Deprecated: Use ServerInitDeclaration.Name() after planning so name collisions are handled. ServerInit string - // MountServer is the name of the mount function. + // ServerInitDeclaration is the generated Go function name used by the server constructor. + ServerInitDeclaration *codegen.NameDeclaration + // MountServer is the route mount function name kept for existing plugins. + // + // Deprecated: Use MountServerDeclaration.Name() after planning so name collisions are handled. MountServer string + // MountServerDeclaration is the generated Go function name used to mount the service routes. + MountServerDeclaration *codegen.NameDeclaration // ServerService is the name of service function. ServerService string - // ClientStruct is the name of the HTTP client struct. + // ClientStruct is the client type name kept for existing plugins. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning so name collisions are handled. ClientStruct string + // ClientStructDeclaration is the generated Go type name used by the client. + ClientStructDeclaration *codegen.NameDeclaration + // ClientInitDeclaration is the generated Go function name used by the client constructor. + ClientInitDeclaration *codegen.NameDeclaration + // ServerConnConfigurerDeclaration names the server WebSocket configuration type. + ServerConnConfigurerDeclaration *codegen.NameDeclaration + // ServerConnConfigurerInitDeclaration names the server WebSocket configuration constructor. + ServerConnConfigurerInitDeclaration *codegen.NameDeclaration + // ClientConnConfigurerDeclaration names the client WebSocket configuration type. + ClientConnConfigurerDeclaration *codegen.NameDeclaration + // ClientConnConfigurerInitDeclaration names the client WebSocket configuration constructor. + ClientConnConfigurerInitDeclaration *codegen.NameDeclaration + // AppendFSDeclaration names the file system type used for mapped file paths. + AppendFSDeclaration *codegen.NameDeclaration + // AppendPrefixDeclaration names the function that adds a mapped file path prefix. + AppendPrefixDeclaration *codegen.NameDeclaration // ServerBodyAttributeTypes is the list of user types used to // define the request, response and error response type // attributes in the server code. @@ -72,31 +136,26 @@ type ( // define the request, response and error response type // attributes in the client code. ClientBodyAttributeTypes []*TypeData - // ServerTypeNames records the user type names used to define - // the endpoint request and response bodies for server code. It - // is populated once during analysis and acts as a - // deduplication set; file generators must never write to it. - ServerTypeNames map[string]struct{} - // ClientTypeNames records the user type names used to define - // the endpoint request and response bodies for client code. It - // is populated once during analysis and acts as a - // deduplication set; file generators must never write to it. - ClientTypeNames map[string]struct{} // ServerTransformHelpers is the list of transform functions // required by the various server side constructors. ServerTransformHelpers []*codegen.TransformFunctionData // ClientTransformHelpers is the list of transform functions // required by the various client side constructors. ClientTransformHelpers []*codegen.TransformFunctionData - // UnionTypes lists the sum-type unions referenced by the HTTP request and - // response body types. - UnionTypes []*service.UnionTypeData - // Scope initialized with all the server and client types. + // Scope records unique Go names for all server and client types. Scope *codegen.NameScope - // bodies caches the shaped body attributes derived from the - // design expressions during analysis. Shaped bodies are detached - // copies: the analyze pass must never write them back onto the - // design expression tree. + // serverWireTypes stores declarations emitted in the server package. + serverWireTypes *wireTypeCatalog + // clientWireTypes stores declarations emitted in the client package. + clientWireTypes *wireTypeCatalog + // clientBodyConstructors contains planned constructors for unnamed + // request and streamed request body values. + clientBodyConstructors map[clientBodyConstructorKey]*codegen.NameDeclaration + // transforms contains the exact conversions selected while this + // service's HTTP shapes were planned. + transforms plannedWireTransforms + // bodies stores copied request and response fields after applying the HTTP + // mappings. Building service data must never change the input design. bodies shapedBodies } @@ -143,17 +202,45 @@ type ( // server - // MountHandler is the name of the mount handler function. + // MountHandler is the endpoint mount function name kept for existing plugins. + // + // Deprecated: Use MountHandlerDeclaration.Name() after planning so name collisions are handled. MountHandler string - // HandlerInit is the name of the constructor function for the - // http handler function. + // MountHandlerDeclaration is the generated Go function name used to mount this endpoint. + MountHandlerDeclaration *codegen.NameDeclaration + // ServerHandlerWrappers lists functions that surround the handler before + // this endpoint's mount function registers its routes. + ServerHandlerWrappers []*codegen.NameDeclaration + // HandlerInit is the handler constructor name kept for existing plugins. + // + // Deprecated: Use HandlerInitDeclaration.Name() after planning so name collisions are handled. HandlerInit string - // RequestDecoder is the name of the request decoder function. + // HandlerInitDeclaration is the generated Go function name used to create + // this endpoint's handler. + HandlerInitDeclaration *codegen.NameDeclaration + // RequestDecoder is the request decoder name kept for existing plugins. + // + // Deprecated: Use RequestDecoderDeclaration.Name() after planning so name collisions are handled. RequestDecoder string - // ResponseEncoder is the name of the response encoder function. + // RequestDecoderDeclaration is the generated Go function name used to + // decode this endpoint's request. + RequestDecoderDeclaration *codegen.NameDeclaration + // ResponseEncoder is the response encoder name kept for existing plugins. + // + // Deprecated: Use ResponseEncoderDeclaration.Name() after planning so name collisions are handled. ResponseEncoder string - // ErrorEncoder is the name of the error encoder function. + // ResponseEncoderDeclaration is the generated Go function name used to + // encode this endpoint's response. + ResponseEncoderDeclaration *codegen.NameDeclaration + // ErrorEncoder is the error encoder name kept for existing plugins. + // + // Deprecated: Use ErrorEncoderDeclaration.Name() after planning so name collisions are handled. ErrorEncoder string + // ErrorEncoderDeclaration is the generated Go function name used to encode + // this endpoint's errors. + ErrorEncoderDeclaration *codegen.NameDeclaration + // DiscardStreamDeclaration names the no-output stream used by a mixed-result request. + DiscardStreamDeclaration *codegen.NameDeclaration // MultipartRequestDecoder indicates the request decoder for // multipart content type. MultipartRequestDecoder *MultipartData @@ -165,38 +252,65 @@ type ( SSE *SSEData // Redirect defines a redirect for the endpoint. Redirect *RedirectData - // HasMixedResults indicates if the method has both Result and StreamingResult - // defined with different types, enabling content negotiation. + // HasMixedResults indicates that HTTP clients may request one normal result + // or a stream of results. HasMixedResults bool // client - // ClientStruct is the name of the HTTP client struct. + // ClientStruct is the client type name kept for existing plugins. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning so name collisions are handled. ClientStruct string + // ClientStructDeclaration supplies the client type name used by endpoint methods. + ClientStructDeclaration *codegen.NameDeclaration // EndpointInit is the name of the constructor function for the // client endpoint. EndpointInit string // RequestInit is the request builder function. RequestInit *InitData - // RequestEncoder is the name of the request encoder function. + // RequestEncoder is the request encoder name kept for existing plugins. + // + // Deprecated: Use RequestEncoderDeclaration.Name() after planning so name collisions are handled. RequestEncoder string - // ResponseDecoder is the name of the response decoder function. + // RequestEncoderDeclaration is the generated Go function name used to + // encode this endpoint's request. + RequestEncoderDeclaration *codegen.NameDeclaration + // ResponseDecoder is the response decoder name kept for existing plugins. + // + // Deprecated: Use ResponseDecoderDeclaration.Name() after planning so name collisions are handled. ResponseDecoder string + // ResponseDecoderDeclaration is the generated Go function name used to + // decode this endpoint's response. + ResponseDecoderDeclaration *codegen.NameDeclaration // MultipartRequestEncoder indicates the request encoder for // multipart content type. MultipartRequestEncoder *MultipartData // ClientWebSocket holds the data to render the client struct which // implements the client stream interface. ClientWebSocket *WebSocketData - // BuildStreamPayload is the name of the function used to create the - // payload for endpoints that use SkipRequestBodyEncodeDecode. + // BuildStreamPayload is the streamed request helper name kept for existing plugins. + // + // Deprecated: Use BuildStreamPayloadDeclaration.Name() after planning so name collisions are handled. BuildStreamPayload string + // BuildStreamPayloadDeclaration is the generated Go function name used to + // build streamed requests. + BuildStreamPayloadDeclaration *codegen.NameDeclaration + // CLIPayloadDeclaration is the generated Go function name used to build command-line payloads. + CLIPayloadDeclaration *codegen.NameDeclaration } // FileServerData lists the data needed to generate file servers. FileServerData struct { - // MountHandler is the name of the mount handler function. + // MountHandler is the file server mount function name kept for existing plugins. + // + // Deprecated: Use MountHandlerDeclaration.Name() after planning so name collisions are handled. MountHandler string + // MountHandlerDeclaration is the generated Go function name used to mount this file server. + MountHandlerDeclaration *codegen.NameDeclaration + // ServerHandlerWrappers lists functions that surround the handler before + // this file server's mount function registers its routes. + ServerHandlerWrappers []*codegen.NameDeclaration // RequestPaths is the set of HTTP paths to the server. RequestPaths []string // Root is the root server file path. @@ -231,6 +345,8 @@ type ( Name string // Ref is the fully qualified reference to the payload type. Ref string + // CLIPlan describes how command-line text becomes the complete payload. + CLIPlan *cli.FlagPlan // Request contains the data for the corresponding HTTP request. Request *RequestData // DecoderReturnValue is a reference to the decoder return value @@ -391,11 +507,44 @@ type ( // ViewedResult indicates whether the response body type is a // result type. ViewedResult *service.ViewedResultTypeData + // ViewedRepresentations lists the body type and constructor used for each + // legal result view. A response that supports several views includes its + // view name so the client can choose the matching entry. + ViewedRepresentations []*ViewedRepresentationData + } + + // ViewedRepresentationData describes the HTTP body used for one legal result + // view. The server constructor converts the service result into ServerBody. + // The client decodes ClientBody and ResultInit rebuilds the service result. + ViewedRepresentationData struct { + // View is the exact design view name carried on variable-view messages. + View string + // ResultAttr is the Go field selected by Body("name"). It is empty when + // the response body uses the complete result containing the selected view's + // fields. + ResultAttr string + // ServerBody is the body type encoded by the server for View. + ServerBody *TypeData + // ClientBody is the body type decoded by the client for View. + ClientBody *TypeData + // ClientDataPointer reports whether the SSE data line is assigned to + // a pointer field in ClientBody. + ClientDataPointer bool + // ResultInit rebuilds the result containing the selected view's fields from + // ClientBody. + ResultInit *InitData } // InitData contains the data required to render a constructor. InitData struct { - // Name is the constructor function name. + // Declaration is the generated Go function name used by this constructor. + Declaration *codegen.NameDeclaration + // ClientDeclaration is the generated Go function name written in the client package + // when the same path constructor is also written in the server package. + ClientDeclaration *codegen.NameDeclaration + // Name is the constructor function name kept for existing plugins. + // + // Deprecated: Use Declaration.Name() after planning so name collisions are handled. Name string // Description is the function description. Description string @@ -453,6 +602,8 @@ type ( TypeName string // TypeRef is the generated attribute type reference. TypeRef string + // ElemTypeRef is the generated element type reference for an array. + ElemTypeRef string // Description is the attribute description as defined in the design. Description string // FieldName is the name of the data structure field that should @@ -468,11 +619,14 @@ type ( DefaultValue any // Validate contains the validation code for the attribute value if any. Validate string + // CLIPlan describes how command-line text becomes this attribute value and + // how the generated payload builder validates it. + CLIPlan *cli.FlagPlan // Example is an example attribute value Example any - // IsAliased is true if the field type is a user-defined type (alias). + // IsAliased is true when the field uses a user-defined type. IsAliased bool - // ServiceTypeRef is the service-aware type reference for cross-service resolution. + // ServiceTypeRef is the Go type used when the field comes from another service. ServiceTypeRef string // IsTextUnmarshaler is true if the attribute has a struct:field:type meta // whose underlying DSL type is string and the custom type is expected to @@ -529,7 +683,7 @@ type ( // HeaderData describes a HTTP request or response header. HeaderData struct { *Element - // CanonicalName is the canonical header key. + // CanonicalName is the standard HTTP header spelling. CanonicalName string } @@ -554,7 +708,13 @@ type ( TypeData struct { // Name is the type name. Name string - // VarName is the Go type name. + // Declaration is the generated Go type name. + Declaration *codegen.NameDeclaration + // VarName is the Go type spelling kept for existing plugins. When + // Declaration is nonnil, it matches Declaration.Name(). Otherwise it is a + // Go expression such as []string that does not declare a named type. + // + // Deprecated: Use Declaration.Name() after planning so name collisions are handled. VarName string // Description is the type human description. Description string @@ -567,21 +727,54 @@ type ( Ref string // ValidateDef contains the validation code. ValidateDef string - // ValidateRef contains the call to the validation code. + // NestedValidateDef contains validation code whose error paths begin with + // the path passed by another generated validator. + NestedValidateDef string + // ValidateRef contains inline validation code when no named validator is called. ValidateRef string + // ValidationTarget is the value passed to ValidatorDeclaration. It is empty + // when this body does not need a named validator call. + ValidationTarget string + // ValidatorDeclaration is the generated Go function name that runs ValidateDef. + ValidatorDeclaration *codegen.NameDeclaration + // ValidatorName is the validator name kept for existing plugins. + // + // Deprecated: Use ValidatorDeclaration.Name() after planning so name collisions are handled. + ValidatorName string + // NestedValidatorDeclaration is the private generated Go function name used + // when this type appears inside another HTTP body value. + NestedValidatorDeclaration *codegen.NameDeclaration + // NestedValidatorName is the nested validator name kept for existing plugins. + // + // Deprecated: Use NestedValidatorDeclaration.Name() after planning so name collisions are handled. + NestedValidatorName string // Example is an example value for the type. Example any // View is the view used to render the (result) type if any. View string + // declaration points to the one request or response type record that the HTTP + // plan uses for this generated type. + declaration *wireTypeRecord + // attribute is the copied HTTP type whose generated names produced Def and + // Ref. Example generators use it to qualify nested request body types. + attribute *expr.AttributeExpr } // MultipartData contains the data needed to render multipart // encoder/decoder. MultipartData struct { - // FuncName is the name used to generate function type. + // FuncName is the multipart function type or helper name kept for existing plugins. + // + // Deprecated: Use FuncDeclaration.Name() after planning so name collisions are handled. FuncName string - // InitName is the name of the constructor. + // FuncDeclaration is the generated Go name used by the multipart function type or helper. + FuncDeclaration *codegen.NameDeclaration + // InitName is the multipart constructor name kept for existing plugins. + // + // Deprecated: Use InitDeclaration.Name() after planning so name collisions are handled. InitName string + // InitDeclaration is the generated Go function name used by the multipart constructor. + InitDeclaration *codegen.NameDeclaration // VarName is the name of the variable referring to the function. VarName string // ServiceName is the name of the service. @@ -599,17 +792,23 @@ type ( // report messages. httpElementKind string - // shapedBodies caches the detached body attributes computed from the - // design expressions: request and response bodies are shaped with - // makeHTTPType while streaming bodies are plain copies. Caching - // guarantees the shaping runs once per expression and that all the - // consumers share the same attribute instances, which keeps the - // example generator call sequence stable. + // shapedBodies stores each HTTP body after it is copied from the design. + // Requests, responses, and streamed results use their HTTP field names; + // streamed requests keep their authored fields. Reusing each copy gives + // every generated file the same body and the same example values. shapedBodies struct { - requests map[*expr.HTTPEndpointExpr]*expr.AttributeExpr - streams map[*expr.HTTPEndpointExpr]*expr.AttributeExpr - responses map[*expr.HTTPResponseExpr]*expr.AttributeExpr - errors map[*expr.HTTPErrorExpr]*expr.AttributeExpr + requests map[*expr.HTTPEndpointExpr]*expr.AttributeExpr + streams map[*expr.HTTPEndpointExpr]*expr.AttributeExpr + streamResults map[*expr.HTTPEndpointExpr]*expr.AttributeExpr + responses map[*expr.HTTPResponseExpr]*expr.AttributeExpr + errors map[*expr.HTTPErrorExpr]*expr.AttributeExpr + } + + // releasedWireTypePair stops recursive response types after their current + // and released names have been paired once. + releasedWireTypePair struct { + current expr.UserType + released expr.UserType } ) @@ -624,8 +823,9 @@ const ( cookieElement httpElementKind = "cookie" ) -// NewServicesData creates a new ServicesData instance for the given service data. -func NewServicesData(services *service.ServicesData, expressions *expr.HTTPExpr) *ServicesData { +// newServicesData creates the HTTP service map that Plan.Link fills before it +// builds any generated file. +func newServicesData(services *service.ServicesData, expressions *expr.HTTPExpr) *ServicesData { return &ServicesData{ ServicesData: services, Expressions: expressions, @@ -633,27 +833,10 @@ func NewServicesData(services *service.ServicesData, expressions *expr.HTTPExpr) } } -// NewJSONRPCServicesData creates a new ServicesData instance for the JSON-RPC -// transport: file constructors write under gen/jsonrpc and use the JSON-RPC -// label in generated file headers. -func NewJSONRPCServicesData(services *service.ServicesData, expressions *expr.HTTPExpr) *ServicesData { - data := NewServicesData(services, expressions) - data.jsonrpc = true - return data -} - -// Get retrieves the transport data for the service with the given name -// computing it if needed. It returns nil if there is no service with the given -// name. +// Get returns the generated HTTP information for the service with the given +// name. A missing entry means the design does not expose that service over the +// protocol handled by this plan. func (sds *ServicesData) Get(name string) *ServiceData { - if data, ok := sds.HTTPData[name]; ok { - return data - } - svc := sds.Expressions.Service(name) - if svc == nil { - return nil - } - sds.HTTPData[name] = sds.analyze(svc) return sds.HTTPData[name] } @@ -690,24 +873,71 @@ func (sds *ServicesData) label() string { // It records the user types needed by the service definition in userTypes. func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { svc := sds.ServicesData.Get(httpSvc.ServiceExpr.Name) + transportService := *svc + clientOutputPackage := path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "client") + transportService.PkgName = sds.ServiceImport(clientOutputPackage, svc.Name).Name + svc = &transportService scope := codegen.NewNameScope() scope.Unique("c") // 'c' is reserved as the client's receiver name. scope.Unique("v") // 'v' is reserved as the request builder payload argument name. // Reserve 'websocket' to avoid collision with gorilla/websocket scope.Unique("websocket") - // Reserve the service package name to avoid collision with parameter names in generated code + // Reserve the service package alias to avoid collision with parameter names in generated code. scope.Unique(svc.PkgName) + planned := sds.plannedWireTypes[httpSvc] + if planned == nil { + panic(fmt.Sprintf("HTTP service %q has no planned generated types", httpSvc.Name())) + } + planned.server.Link() + planned.client.Link() + symbols := sds.plannedSymbols[httpSvc] + if symbols == nil { + panic(fmt.Sprintf("HTTP service %q has no package names", httpSvc.Name())) + } + clientPkgName := strings.ToLower(codegen.Goify(svc.Name, false)) + "c" + serverPkgName := strings.ToLower(codegen.Goify(svc.Name, false)) + "svr" + if sds.jsonrpc { + serverPkgName = strings.ToLower(codegen.Goify(svc.Name, false)) + "jssvr" + } + for _, server := range sds.Root.API.Servers { + if !slices.Contains(server.Services, svc.Name) { + continue + } + serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) + cliOutputPackage := path.Join(sds.GenPkg(), sds.dir(), "cli", serverName) + exampleOutputPackage := path.Join(path.Dir(sds.GenPkg()), "cmd", serverName) + clientPkgName = sds.PackageImport(cliOutputPackage, clientOutputPackage).Name + serverPkgName = sds.PackageImport(exampleOutputPackage, path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "server")).Name + break + } sd := &ServiceData{ - Service: svc, - ServerStruct: "Server", - MountPointStruct: "MountPoint", - ServerInit: "New", - MountServer: "Mount", - ServerService: "Service", - ClientStruct: "Client", - ServerTypeNames: make(map[string]struct{}), - ClientTypeNames: make(map[string]struct{}), - Scope: scope, + Service: svc, + ClientPkgName: clientPkgName, + ServerPkgName: serverPkgName, + ServerStruct: symbols.serverStruct.Name(), + ServerStructDeclaration: symbols.serverStruct, + MountPointStruct: symbols.mountPoint.Name(), + MountPointStructDeclaration: symbols.mountPoint, + ServerInit: symbols.serverInit.Name(), + ServerInitDeclaration: symbols.serverInit, + MountServer: symbols.mountServer.Name(), + MountServerDeclaration: symbols.mountServer, + ServerService: "Service", + ClientStruct: symbols.clientStruct.Name(), + ClientStructDeclaration: symbols.clientStruct, + ClientInitDeclaration: symbols.clientInit, + ServerConnConfigurerDeclaration: symbols.serverConfigurer, + ServerConnConfigurerInitDeclaration: symbols.serverConfigurerInit, + ClientConnConfigurerDeclaration: symbols.clientConfigurer, + ClientConnConfigurerInitDeclaration: symbols.clientConfigurerInit, + AppendFSDeclaration: symbols.appendFS, + AppendPrefixDeclaration: symbols.appendPrefix, + Scope: scope, + serverWireTypes: planned.server, + clientWireTypes: planned.client, + clientBodyConstructors: planned.clientBodyConstructors, + transforms: planned.transforms, + bodies: planned.bodies, } for _, s := range httpSvc.FileServers { @@ -735,14 +965,15 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } } data := &FileServerData{ - MountHandler: scope.Unique(fmt.Sprintf("Mount%s", codegen.Goify(s.FilePath, true))), - RequestPaths: paths, - FilePath: s.FilePath, - IsDir: s.IsDir(), - PathParam: pp, - Redirect: redirect, - VarName: scope.Unique(codegen.Goify(s.FilePath, true)), - ArgName: scope.Unique(fmt.Sprintf("fileSystem%s", codegen.Goify(s.FilePath, true))), + MountHandler: symbols.fileServers[s].Name(), + MountHandlerDeclaration: symbols.fileServers[s], + RequestPaths: paths, + FilePath: s.FilePath, + IsDir: s.IsDir(), + PathParam: pp, + Redirect: redirect, + VarName: scope.Unique(codegen.Goify(s.FilePath, true)), + ArgName: scope.Unique(fmt.Sprintf("fileSystem%s", codegen.Goify(s.FilePath, true))), } sd.FileServers = append(sd.FileServers, data) } @@ -755,6 +986,10 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { routesCap += len(r.FullPaths()) } routes := make([]*RouteData, 0, routesCap) + endpointSymbols := symbols.endpoints[httpEndpoint] + if endpointSymbols == nil { + panic(fmt.Sprintf("HTTP endpoint %q has no package names", httpEndpoint.Name())) + } pathCount := 0 for _, r := range httpEndpoint.Routes { for _, rpath := range r.FullPaths() { @@ -765,12 +1000,8 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { { initArgs := make([]*InitArgData, len(params)) pathParamsObj := expr.AsObject(httpEndpoint.PathParams().Type) - suffix := "" - if pathCount > 0 { - suffix = strconv.Itoa(pathCount + 1) - } - pathCount++ - name := fmt.Sprintf("%s%sPath%s", method.VarName, svc.StructName, suffix) + declaration := endpointSymbols.serverPaths[pathCount] + name := declaration.Name() for j, arg := range params { patt := pathParamsObj.Attribute(arg) att := makeHTTPType(patt) @@ -798,7 +1029,7 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { Type: att.Type, Pointer: pointer, Required: true, - Example: att.Example(sds.Root.API.ExampleGenerator.Field(httpEndpoint.MethodExpr.Payload, arg)), + Example: sds.FieldExample(att, httpEndpoint.MethodExpr.Payload, arg, expr.MethodPayloadExampleIdentity(httpEndpoint.MethodExpr)), Validate: vcode, }, } @@ -827,14 +1058,16 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } } init = &InitData{ - Name: name, - Description: fmt.Sprintf("%s returns the URL path to the %s service %s HTTP endpoint. ", name, svc.Name, method.Name), - ServerArgs: initArgs, - ClientArgs: clientArgs, - ReturnTypeName: "string", - ReturnTypeRef: "string", - ServerCode: buffer.String(), - ClientCode: buffer.String(), + Declaration: declaration, + ClientDeclaration: endpointSymbols.clientPaths[pathCount], + Name: name, + Description: fmt.Sprintf("%s returns the URL path to the %s service %s HTTP endpoint. ", name, svc.Name, method.Name), + ServerArgs: initArgs, + ClientArgs: clientArgs, + ReturnTypeName: "string", + ReturnTypeRef: "string", + ServerCode: buffer.String(), + ClientCode: buffer.String(), } } @@ -843,6 +1076,7 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { Path: rpath, PathInit: init, }) + pathCount++ } } @@ -877,22 +1111,14 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { reqs = append(reqs, &service.RequirementData{Schemes: rs, Scopes: req.Scopes}) } - var requestEncoder string - if httpEndpoint.IsJSONRPC() || payload.Request.ClientBody != nil || len(payload.Request.Headers) > 0 || len(payload.Request.QueryParams) > 0 || len(payload.Request.Cookies) > 0 || basch != nil { - // JSON-RPC endpoints always need a request encoder to build - // the JSON-RPC envelope, even when the payload is empty. - requestEncoder = fmt.Sprintf("Encode%sRequest", method.VarName) - } - var requestInit *InitData var ( - name string args []*InitArgData payloadRef string pkg string ) { - name = fmt.Sprintf("Build%sRequest", method.VarName) + svcctx := sds.serviceTypeContext(sd, "client").Enter(httpEndpoint.MethodExpr.Payload) s := codegen.NewNameScope() s.Unique("c") // 'c' is reserved as the client's receiver name. for _, ca := range routes[0].PathInit.ClientArgs { @@ -902,14 +1128,15 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { // Populate service-aware type resolution fields _, ca.IsAliased = ca.FieldType.(expr.UserType) if ca.IsAliased { - ca.ServiceTypeRef = sds.ServicesData.Get(svc.Name).Scope.GoTypeRef(&expr.AttributeExpr{Type: ca.Type}) + attribute := &expr.AttributeExpr{Type: ca.Type} + ca.ServiceTypeRef = svcctx.Scope.Ref(attribute, svcctx.Pkg(attribute)) } args = append(args, ca) } } - pkg = method.PayloadLoc.PackageNameOrDefault(svc.PkgName) + pkg = svc.PkgName if len(routes[0].PathInit.ClientArgs) > 0 && httpEndpoint.MethodExpr.Payload.Type != expr.Empty { - payloadRef = svc.Scope.GoFullTypeRef(httpEndpoint.MethodExpr.Payload, pkg) + payloadRef = svcctx.Scope.Ref(httpEndpoint.MethodExpr.Payload, svcctx.Pkg(httpEndpoint.MethodExpr.Payload)) } } data := map[string]any{ @@ -931,66 +1158,105 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } clientArgs := []*InitArgData{{Ref: "v", AttributeData: &AttributeData{Name: "payload", VarName: "v", TypeRef: "any"}}} requestInit = &InitData{ - Name: name, - Description: fmt.Sprintf("%s instantiates a HTTP request object with method and path set to call the %q service %q endpoint", name, svc.Name, method.Name), + Declaration: endpointSymbols.requestBuilder, + Name: endpointSymbols.requestBuilder.Name(), + Description: fmt.Sprintf("%s instantiates a HTTP request object with method and path set to call the %q service %q endpoint", endpointSymbols.requestBuilder.Name(), svc.Name, method.Name), ClientCode: buf.String(), ClientArgs: clientArgs, } ed := &EndpointData{ - Method: method, - IsJSONRPC: httpEndpoint.IsJSONRPC(), - ServiceName: svc.Name, - ServiceVarName: svc.VarName, - ServicePkgName: svc.PkgName, - Payload: payload, - Result: sds.buildResultData(httpEndpoint, sd), - Errors: sds.buildErrorsData(httpEndpoint, sd), - HeaderSchemes: hsch, - BodySchemes: bosch, - QuerySchemes: qsch, - BasicScheme: basch, - Routes: routes, - MountHandler: fmt.Sprintf("Mount%sHandler", method.VarName), - HandlerInit: fmt.Sprintf("New%sHandler", method.VarName), - RequestDecoder: fmt.Sprintf("Decode%sRequest", method.VarName), - ResponseEncoder: fmt.Sprintf("Encode%sResponse", method.VarName), - ErrorEncoder: fmt.Sprintf("Encode%sError", method.VarName), - ClientStruct: "Client", - EndpointInit: method.VarName, - RequestInit: requestInit, - HasMixedResults: httpEndpoint.MethodExpr.HasMixedResults(), - RequestEncoder: requestEncoder, - ResponseDecoder: fmt.Sprintf("Decode%sResponse", method.VarName), - Requirements: reqs, + Method: method, + IsJSONRPC: httpEndpoint.IsJSONRPC(), + ServiceName: svc.Name, + ServiceVarName: svc.VarName, + ServicePkgName: svc.PkgName, + Payload: payload, + Result: sds.buildResultData(httpEndpoint, sd), + Errors: sds.buildErrorsData(httpEndpoint, sd), + HeaderSchemes: hsch, + BodySchemes: bosch, + QuerySchemes: qsch, + BasicScheme: basch, + Routes: routes, + MountHandler: endpointSymbols.mountHandler.Name(), + MountHandlerDeclaration: endpointSymbols.mountHandler, + HandlerInit: endpointSymbols.handlerInit.Name(), + HandlerInitDeclaration: endpointSymbols.handlerInit, + RequestDecoderDeclaration: endpointSymbols.requestDecoder, + ResponseEncoderDeclaration: endpointSymbols.responseEncoder, + ErrorEncoderDeclaration: endpointSymbols.errorEncoder, + DiscardStreamDeclaration: endpointSymbols.discardStream, + ClientStruct: symbols.clientStruct.Name(), + ClientStructDeclaration: symbols.clientStruct, + EndpointInit: method.VarName, + RequestInit: requestInit, + HasMixedResults: httpEndpoint.MethodExpr.HasMixedResults(), + RequestEncoderDeclaration: endpointSymbols.requestEncoder, + ResponseDecoder: endpointSymbols.responseDecoder.Name(), + ResponseDecoderDeclaration: endpointSymbols.responseDecoder, + Requirements: reqs, + } + if declaration := endpointSymbols.requestDecoder; declaration != nil { + ed.RequestDecoder = declaration.Name() + } + if declaration := endpointSymbols.responseEncoder; declaration != nil { + ed.ResponseEncoder = declaration.Name() + } + if declaration := endpointSymbols.errorEncoder; declaration != nil { + ed.ErrorEncoder = declaration.Name() + } + if declaration := endpointSymbols.requestEncoder; declaration != nil { + ed.RequestEncoder = declaration.Name() } if httpEndpoint.MethodExpr.IsStreaming() { sds.initWebSocketData(ed, httpEndpoint, sd) - initSSEData(ed, httpEndpoint, sd) + sds.initSSEData(ed, httpEndpoint, sd) + if ed.ServerWebSocket != nil { + ed.ServerWebSocket.VarDeclaration = endpointSymbols.serverStream + ed.ServerWebSocket.VarName = endpointSymbols.serverStream.Name() + } + if ed.ClientWebSocket != nil { + ed.ClientWebSocket.VarDeclaration = endpointSymbols.clientStream + ed.ClientWebSocket.VarName = endpointSymbols.clientStream.Name() + } + if ed.SSE != nil { + ed.SSE.StructName = endpointSymbols.serverStream.Name() + ed.SSE.StructDeclaration = endpointSymbols.serverStream + ed.SSE.ClientInterfaceDeclaration = endpointSymbols.sseClientInterface + ed.SSE.ClientStructDeclaration = endpointSymbols.sseClientStruct + ed.SSE.ClientInitDeclaration = endpointSymbols.sseClientInit + } } if httpEndpoint.MultipartRequest { ed.MultipartRequestDecoder = &MultipartData{ - FuncName: fmt.Sprintf("%s%sDecoderFunc", svc.StructName, method.VarName), - InitName: fmt.Sprintf("New%s%sDecoder", svc.StructName, method.VarName), - VarName: fmt.Sprintf("%s%sDecoderFn", svc.VarName, method.VarName), - ServiceName: svc.Name, - MethodName: method.Name, - Payload: ed.Payload, + FuncName: endpointSymbols.serverMultipart.functionType.Name(), + FuncDeclaration: endpointSymbols.serverMultipart.functionType, + InitName: endpointSymbols.serverMultipart.constructor.Name(), + InitDeclaration: endpointSymbols.serverMultipart.constructor, + VarName: fmt.Sprintf("%s%sDecoderFn", svc.VarName, method.VarName), + ServiceName: svc.Name, + MethodName: method.Name, + Payload: ed.Payload, } ed.MultipartRequestEncoder = &MultipartData{ - FuncName: fmt.Sprintf("%s%sEncoderFunc", svc.StructName, method.VarName), - InitName: fmt.Sprintf("New%s%sEncoder", svc.StructName, method.VarName), - VarName: fmt.Sprintf("%s%sEncoderFn", svc.VarName, method.VarName), - ServiceName: svc.Name, - MethodName: method.Name, - Payload: ed.Payload, + FuncName: endpointSymbols.clientMultipart.functionType.Name(), + FuncDeclaration: endpointSymbols.clientMultipart.functionType, + InitName: endpointSymbols.clientMultipart.constructor.Name(), + InitDeclaration: endpointSymbols.clientMultipart.constructor, + VarName: fmt.Sprintf("%s%sEncoderFn", svc.VarName, method.VarName), + ServiceName: svc.Name, + MethodName: method.Name, + Payload: ed.Payload, } } if httpEndpoint.SkipRequestBodyEncodeDecode { - ed.BuildStreamPayload = scope.Unique("Build" + codegen.Goify(method.Name, true) + "StreamPayload") + ed.BuildStreamPayload = endpointSymbols.buildStreamPayload.Name() + ed.BuildStreamPayloadDeclaration = endpointSymbols.buildStreamPayload } + ed.CLIPayloadDeclaration = endpointSymbols.cliPayload if httpEndpoint.Redirect != nil { ed.Redirect = &RedirectData{ @@ -1003,80 +1269,687 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } for _, a := range httpSvc.HTTPEndpoints { - collectUserTypes(sd.bodies.request(a).Type, func(ut expr.UserType) { - if d := sds.attributeTypeData(ut, true, true, true, sd); d != nil { - sd.ServerBodyAttributeTypes = append(sd.ServerBodyAttributeTypes, d) + sds.buildRequestAttributeTypes(sd.bodies.request(a), sd) + + if a.MethodExpr.StreamingPayload.Type != expr.Empty { + sds.buildRequestAttributeTypes(sd.bodies.streaming(a), sd) + } + } + + return sd +} + +// buildRequestAttributeTypes builds nested request declarations from separate +// tagged copies because server and client packages apply different pointer and +// default policies to the same authored body graph. +func (sds *ServicesData) buildRequestAttributeTypes(body *expr.AttributeExpr, data *ServiceData) { + for _, side := range []struct { + server bool + pointer bool + }{ + {server: true, pointer: true}, + {server: false, pointer: false}, + } { + body := expr.DupAtt(body) + addMarshalTags(body) + top, _ := body.Type.(expr.UserType) + collectUserTypes(body.Type, func(userType expr.UserType) { + if top != nil && userType.Origin() == top.Origin() { + return } - if d := sds.attributeTypeData(ut, true, false, false, sd); d != nil { - sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) + declaration := sds.attributeTypeData(userType, true, side.pointer, side.server, data) + if declaration == nil { + return + } + if side.server { + data.ServerBodyAttributeTypes = append(data.ServerBodyAttributeTypes, declaration) + } else { + data.ClientBodyAttributeTypes = append(data.ClientBodyAttributeTypes, declaration) } }) + } +} - if a.MethodExpr.StreamingPayload.Type != expr.Empty { - collectUserTypes(sd.bodies.streaming(a).Type, func(ut expr.UserType) { - if d := sds.attributeTypeData(ut, true, true, true, sd); d != nil { - sd.ServerBodyAttributeTypes = append(sd.ServerBodyAttributeTypes, d) +// collectPlannedWireTypes records every request and response type written by +// the generated client and server packages. NewPlans calls it before +// Generation.Freeze chooses Go names, and Link later uses these same copied +// values. +func collectPlannedWireTypes(api string, httpService *expr.HTTPServiceExpr, planned *plannedWireTypes, servicePlan *service.Plan) { + bodies, server, client := &planned.bodies, planned.server, planned.client + for _, endpoint := range httpService.HTTPEndpoints { + request := expr.DupAtt(bodies.request(endpoint)) + addMarshalTags(request) + serverRequestPolicy := jsonBodyPolicy(true, true, true, "") + clientRequestPolicy := jsonBodyPolicy(true, false, false, "") + server.collect(request, wireRequestBody, serverRequestPolicy, api) + server.addValidationRoot(request, serverRequestPolicy) + clientRequest := client.collect(request, wireRequestBody, clientRequestPolicy, api) + if userType, named := request.Type.(expr.UserType); named && userType.Attribute().Validation != nil { + client.addValidationRoot(request, clientRequestPolicy) + } + if clientRequest != nil && needInit(request.Type) { + clientRequest.needsConstructor = true + } else if needInit(request.Type) { + key := clientBodyConstructorKey{endpoint: endpoint, role: wireRequestBody} + planned.clientBodyConstructorNames[key] = anonymousClientBodyConstructorName(request, clientRequestPolicy) + } + server.collectChildren(request, jsonBodyPolicy(true, true, true, ""), api) + client.collectChildren(request, jsonBodyPolicy(true, false, true, ""), api) + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + streaming := expr.DupAtt(bodies.streaming(endpoint)) + addMarshalTags(streaming) + serverStreamPolicy := jsonBodyPolicy(true, true, true, "") + clientStreamPolicy := jsonBodyPolicy(true, false, false, "") + serverStream := server.collect(streaming, wireStreamPayload, serverStreamPolicy, api) + server.addValidationRoot(streaming, serverStreamPolicy) + if endpoint.UsesWebSocket() && needInit(endpoint.MethodExpr.StreamingPayload.Type) && serverStream != nil { + serverStream.needsConstructor = true + planned.streamPayloads[endpoint] = serverStream + } + clientStream := client.collect(streaming, wireStreamPayload, clientStreamPolicy, api) + if userType, named := streaming.Type.(expr.UserType); !named || userType.Attribute().Validation != nil { + client.addValidationRoot(streaming, clientStreamPolicy) + } + if clientStream != nil && needInit(streaming.Type) { + clientStream.needsConstructor = true + } else if needInit(streaming.Type) { + key := clientBodyConstructorKey{endpoint: endpoint, role: wireStreamPayload} + planned.clientBodyConstructorNames[key] = anonymousClientBodyConstructorName(streaming, clientStreamPolicy) + } + server.collectChildren(streaming, jsonBodyPolicy(true, true, true, ""), api) + client.collectChildren(streaming, jsonBodyPolicy(true, false, true, ""), api) + } + if endpoint.UsesSSE() && endpoint.MethodExpr.HasMixedResults() { + body := bodies.streamingResult(endpoint) + collectResponseWireType(api, body, body, endpoint, server, true, nil, "") + collectResponseWireType(api, body, body, endpoint, client, false, nil, "") + } + + resultType, viewed := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr) + for _, response := range endpoint.Responses { + body := bodies.response(response) + if !viewed { + collectResponseWireType(api, body, body, endpoint, server, true, nil, "") + collectResponseWireType(api, body, body, endpoint, client, false, nil, "") + continue + } + origin := "" + if value, ok := body.Meta["origin:attribute"]; ok { + origin = value[0] + } + emptyView := "" + switch { + case origin != "": + collectResponseWireType(api, body, body, endpoint, server, true, &emptyView, "") + case endpoint.MethodExpr.Result.Meta != nil: + if view, ok := endpoint.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { + collectResponseWireType(api, body, body, endpoint, server, true, &view, "") + } else { + for _, view := range resultType.Views { + collectResponseWireType(api, body, body, endpoint, server, true, &view.Name, "") + } } - if d := sds.attributeTypeData(ut, true, false, false, sd); d != nil { - sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) + default: + for _, view := range resultType.Views { + collectResponseWireType(api, body, body, endpoint, server, true, &view.Name, "") } + } + clientView := clientResponseViewNameExpr(endpoint, resultType) + if origin != "" { + emptyView := "" + collectResponseWireType(api, body, body, endpoint, client, false, &emptyView, "") + continue + } + if clientView == "" && !endpoint.UsesSSE() && !endpoint.IsJSONRPC() { + emptyView := "" + collectResponseWireType(api, body, body, endpoint, client, false, &emptyView, "") + continue + } + if clientView != "" { + clientBody := effectiveClientResponseBodyForView(body, clientView) + collectResponseWireType(api, clientBody, body, endpoint, client, false, &clientView, "") + continue + } + for _, view := range resultType.Views { + clientBody := effectiveClientResponseBodyForView(body, view.Name) + collectResponseWireType(api, clientBody, body, endpoint, client, false, &view.Name, "") + } + } + for _, transportError := range endpoint.HTTPErrors { + body := bodies.errorResponse(transportError) + collectResponseWireType(api, body, body, endpoint, server, true, nil, transportError.Name) + collectResponseWireType(api, body, body, endpoint, client, false, nil, transportError.Name) + } + collectPlannedTransforms(endpoint, planned, servicePlan) + } +} + +// anonymousClientBodyConstructorName returns the preferred function name for +// a request body that uses a Go expression such as []T instead of declaring a +// package type. +func anonymousClientBodyConstructorName(body *expr.AttributeExpr, policy wireTypePolicy) string { + scope := codegen.NewAttributeScope(codegen.NewNameScope()) + name := scope.Name(body, "", policy.pointer, policy.useDefault) + return "New" + codegen.Goify(name, true) +} + +// collectPlannedTransforms records each request, response, error, and stream +// conversion and stores its handle with the endpoint value that will write it. +// The generated package can then name every helper before Plan.Link. +func collectPlannedTransforms( + endpoint *expr.HTTPEndpointExpr, + planned *plannedWireTypes, + servicePlan *service.Plan, +) { + methodName := endpoint.MethodExpr.Name + bodies := &planned.bodies + server := planned.server + client := planned.client + servicePackage, viewsPackage, err := servicePlan.MethodPackageImports(endpoint.MethodExpr) + if err != nil { + panic(err) + } + request := expr.DupAtt(bodies.request(endpoint)) + addMarshalTags(request) + payload := endpoint.MethodExpr.Payload + if needInit(payload.Type) { + if request.Type != expr.Empty { + target := payload + if origin, ok := request.Meta["origin:attribute"]; ok { + target = expr.AsObject(payload.Type).Attribute(origin[0]) + } + requestTransforms := planned.transforms.request(endpoint, wireRequestBody) + if needInit(request.Type) { + requestTransforms.clientEncode = client.collectTransform(target, request, "marshal", methodName+" request body", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(true, false, false, ""), + servicePackage: *servicePackage, + }) + } + requestTransforms.serverDecode = server.collectTransform(request, target, "unmarshal", methodName+" server payload", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(true, true, false, ""), + servicePackage: *servicePackage, }) + requestTransforms.clientDecode = client.collectTransform(request, target, "marshal", methodName+" command payload", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(true, false, false, ""), + servicePackage: *servicePackage, + }) + } else if expr.IsArray(payload.Type) || expr.IsMap(payload.Type) { + if params := expr.AsObject(endpoint.Params.Type); len(*params) > 0 { + requestTransforms := planned.transforms.request(endpoint, wireRequestBody) + requestTransforms.serverDecode = server.collectTransform((*params)[0].Attribute, payload, "unmarshal", methodName+" server parameters", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: wireTypePolicy{request: true, pointer: true}, + servicePackage: *servicePackage, + }) + requestTransforms.clientDecode = client.collectTransform((*params)[0].Attribute, payload, "marshal", methodName+" command parameters", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: wireTypePolicy{request: true, useDefault: true}, + servicePackage: *servicePackage, + }) + } + } + } + + result := endpoint.MethodExpr.Result + resultType, viewed := result.Type.(*expr.ResultTypeExpr) + resultPackage := *servicePackage + if viewed { + if viewsPackage == nil { + panic(fmt.Sprintf("viewed method %q has no views package", methodName)) + } + resultPackage = *viewsPackage + result, err = servicePlan.ProjectedResult(endpoint.MethodExpr) + if err != nil { + panic(err) + } + } + for _, response := range endpoint.Responses { + body := bodies.response(response) + origin := "" + if value, ok := body.Meta["origin:attribute"]; ok { + origin = value[0] + } + resultAttribute := result + if origin != "" { + resultAttribute = expr.AsObject(result.Type).Attribute(origin) + } + var serverViews []*string + switch { + case !viewed: + serverViews = []*string{nil} + case origin != "": + empty := "" + serverViews = []*string{&empty} + case endpoint.MethodExpr.Result.Meta != nil: + if view, ok := endpoint.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { + serverViews = []*string{&view} + } else { + for index := range resultType.Views { + serverViews = append(serverViews, &resultType.Views[index].Name) + } + } + default: + for index := range resultType.Views { + serverViews = append(serverViews, &resultType.Views[index].Name) + } + } + for _, view := range serverViews { + prepared, viewName := prepareResponseWireBody(body, view) + if prepared.Type != expr.Empty && resultAttribute.Type != expr.Empty && needInit(prepared.Type) { + responseTransforms := planned.transforms.response(endpoint, response, viewName) + responseTransforms.serverEncode = server.collectTransform(resultAttribute, prepared, "marshal", transformResponseOwner(methodName, response, view, "server"), wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(false, true, false, viewName), + servicePointer: view != nil, + servicePackage: resultPackage, + }) + } } - md := sd.Service.Method(a.Name()) - for _, v := range a.Responses { - body := effectiveClientResponseBody(sd.bodies.response(v), a, md) - collectUserTypes(body.Type, func(ut expr.UserType) { - // NOTE: ServerBodyAttributeTypes for response body types are - // collected in buildResponseBodyType because we have to generate - // body types for each view in a result type. - if d := sds.attributeTypeData(ut, false, true, false, sd); d != nil { - sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) + if !needInit(result.Type) { + continue + } + clientViewCount := 1 + if viewed { + clientViewCount += len(resultType.Views) + } + clientViews := make([]*string, 0, clientViewCount) + if !viewed { + clientViews = append(clientViews, nil) + } else { + selected := clientResponseViewNameExpr(endpoint, resultType) + switch { + case origin != "": + empty := "" + clientViews = append(clientViews, &empty) + case selected != "": + clientViews = append(clientViews, &selected) + case !endpoint.UsesSSE() && !endpoint.IsJSONRPC(): + empty := "" + clientViews = append(clientViews, &empty) + default: + for index := range resultType.Views { + clientViews = append(clientViews, &resultType.Views[index].Name) } + } + } + for _, view := range clientViews { + clientBody := body + if view != nil && *view != "" { + clientBody = effectiveClientResponseBodyForView(body, *view) + } + prepared, viewName := prepareResponseWireBody(clientBody, view) + if prepared.Type != expr.Empty { + responseTransforms := planned.transforms.response(endpoint, response, viewName) + responseTransforms.clientDecode = client.collectTransform(prepared, resultAttribute, "unmarshal", transformResponseOwner(methodName, response, view, "client"), wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(false, false, false, viewName), + servicePointer: viewed, + servicePackage: resultPackage, + }) + } + } + if body.Type == expr.Empty && (expr.IsArray(result.Type) || expr.IsMap(result.Type)) { + if params := expr.AsObject(endpoint.QueryParams().Type); len(*params) > 0 { + responseTransforms := planned.transforms.response(endpoint, response, "") + responseTransforms.clientDecode = client.collectTransform((*params)[0].Attribute, result, "unmarshal", transformResponseOwner(methodName, response, nil, "client parameters"), wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: wireTypePolicy{pointer: true}, + servicePointer: viewed, + servicePackage: resultPackage, + }) + } + } + } + + for _, transportError := range endpoint.HTTPErrors { + body, _ := prepareResponseWireBody(bodies.errorResponse(transportError), nil) + target := endpoint.MethodExpr.Error(transportError.Name).AttributeExpr + if origin, ok := body.Meta["origin:attribute"]; ok { + target = expr.AsObject(target.Type).Attribute(origin[0]) + } + if body.Type != expr.Empty && needInit(transportError.Type) { + errorTransforms := planned.transforms.transportError(transportError) + if needInit(body.Type) { + errorTransforms.serverEncode = server.collectTransform(target, body, "marshal", methodName+" server error "+transportError.Name, wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(false, true, false, ""), + servicePackage: *servicePackage, + }) + } + errorTransforms.clientDecode = client.collectTransform(body, target, "unmarshal", methodName+" client error "+transportError.Name, wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(false, false, false, ""), + servicePackage: *servicePackage, }) + } else if body.Type == expr.Empty && (expr.IsArray(transportError.Type) || expr.IsMap(transportError.Type)) { + if params := expr.AsObject(endpoint.QueryParams().Type); len(*params) > 0 { + errorTransforms := planned.transforms.transportError(transportError) + errorTransforms.clientDecode = client.collectTransform((*params)[0].Attribute, endpoint.MethodExpr.Error(transportError.Name).AttributeExpr, "unmarshal", methodName+" client error parameters "+transportError.Name, wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: wireTypePolicy{pointer: true}, + servicePackage: *servicePackage, + }) + } } + } - for _, v := range a.HTTPErrors { - collectUserTypes(sd.bodies.errorResponse(v).Type, func(ut expr.UserType) { - // NOTE: ServerBodyAttributeTypes for error response body types are - // collected in buildResponseBodyType because we have to generate - // body types for each view in a result type. - if d := sds.attributeTypeData(ut, false, true, false, sd); d != nil { - sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) - } + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty && endpoint.UsesWebSocket() { + body := expr.DupAtt(bodies.streaming(endpoint)) + addMarshalTags(body) + if body.Type != expr.Empty && needInit(endpoint.MethodExpr.StreamingPayload.Type) { + requestTransforms := planned.transforms.request(endpoint, wireStreamPayload) + requestTransforms.serverDecode = server.collectTransform(body, endpoint.MethodExpr.StreamingPayload, "marshal", methodName+" server stream payload", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(true, true, false, ""), + servicePackage: *servicePackage, + }) + requestTransforms.clientEncode = client.collectTransform(endpoint.MethodExpr.StreamingPayload, body, "marshal", methodName+" client stream body", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(true, false, false, ""), + servicePackage: *servicePackage, + }) + } + } + if endpoint.UsesSSE() && endpoint.MethodExpr.HasMixedResults() { + body, _ := prepareResponseWireBody(bodies.streamingResult(endpoint), nil) + result := endpoint.MethodExpr.StreamingResult + streamTransforms := planned.transforms.streamingResult(endpoint) + serviceLayout, err := servicePlan.StreamingResultLayout(endpoint.MethodExpr) + if err != nil { + panic(err) + } + direct, err := sameMixedSSERepresentation(body, serviceLayout) + if err != nil { + panic(err) + } + if body.Type != expr.Empty && direct { + streamTransforms.clientDecodeDirect = true + } + if body.Type != expr.Empty && !streamTransforms.clientDecodeDirect { + if needInit(body.Type) { + streamTransforms.serverEncode = server.collectTransform(result, body, "marshal", methodName+" server streaming result", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(false, true, false, ""), + servicePackage: *servicePackage, + }) + } + streamTransforms.clientDecode = client.collectTransform(body, result, "unmarshal", methodName+" client streaming result", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(false, false, false, ""), + servicePackage: *servicePackage, }) } } +} - unionByHash := make(map[string]*service.UnionTypeData) - seenUnionTypes := make(map[string]struct{}) - for _, a := range httpSvc.HTTPEndpoints { - collectHTTPUnionTypes(sd.bodies.request(a), sd.Scope, unionByHash, seenUnionTypes) +// sameMixedSSERepresentation compares the retained service layout with the +// wire layout decoded by the client. Named values, unions, and structs always +// use a planned conversion; primitive values and their collections are direct +// only when every retained Go type detail matches. +func sameMixedSSERepresentation(wire *expr.AttributeExpr, serviceLayout *codegen.GoTypePlan) (bool, error) { + if !mixedSSEDirectLayout(serviceLayout) { + return false, nil + } + wireLayout, err := codegen.PlanGoType(wire, codegen.GoTypePlanOptions{ + Owner: serviceLayout.Owner(), + Policy: serviceLayout.Policy(), + }) + if err != nil { + return false, err + } + return serviceLayout.Equivalent(wireLayout), nil +} - if a.MethodExpr.StreamingPayload.Type != expr.Empty { - collectHTTPUnionTypes(sd.bodies.streaming(a), sd.Scope, unionByHash, seenUnionTypes) +// mixedSSEDirectLayout reports whether a layout can be assigned without any +// generated declaration or field-by-field conversion. +func mixedSSEDirectLayout(layout *codegen.GoTypePlan) bool { + switch layout.Kind() { + case codegen.GoPrimitive: + return true + case codegen.GoArray: + return mixedSSEDirectLayout(layout.Elem()) + case codegen.GoMap: + return mixedSSEDirectLayout(layout.Key()) && mixedSSEDirectLayout(layout.Elem()) + default: + return false + } +} + +// request returns the retained conversions for one ordinary or streamed +// request body, creating the record during collection when needed. +func (p *plannedWireTransforms) request( + endpoint *expr.HTTPEndpointExpr, + role wireTypeRole, +) *plannedRequestTransforms { + key := clientBodyConstructorKey{endpoint: endpoint, role: role} + transforms := p.requests[key] + if transforms == nil { + transforms = &plannedRequestTransforms{} + p.requests[key] = transforms + } + return transforms +} + +// response returns the retained conversions for one status, tag, and view +// representation, creating the record during collection when needed. +func (p *plannedWireTransforms) response( + endpoint *expr.HTTPEndpointExpr, + response *expr.HTTPResponseExpr, + view string, +) *plannedResponseTransforms { + key := viewedConstructorKey{endpoint: endpoint, response: response, view: view} + transforms := p.responses[key] + if transforms == nil { + transforms = &plannedResponseTransforms{} + p.responses[key] = transforms + } + return transforms +} + +// transportError returns the retained conversions for one designed error, +// creating the record during collection when needed. +func (p *plannedWireTransforms) transportError( + transportError *expr.HTTPErrorExpr, +) *plannedResponseTransforms { + transforms := p.errors[transportError] + if transforms == nil { + transforms = &plannedResponseTransforms{} + p.errors[transportError] = transforms + } + return transforms +} + +// streamingResult returns the retained conversions for a mixed method's +// streamed result, creating the record during collection when needed. +func (p *plannedWireTransforms) streamingResult( + endpoint *expr.HTTPEndpointExpr, +) *plannedResponseTransforms { + transforms := p.streamingResults[endpoint] + if transforms == nil { + transforms = &plannedResponseTransforms{} + p.streamingResults[endpoint] = transforms + } + return transforms +} + +// transformResponseOwner returns the method, transport side, status, tag, and +// view values that distinguish helper functions for two responses with the +// same generated Go types. +func transformResponseOwner(method string, response *expr.HTTPResponseExpr, view *string, side string) string { + viewName := "" + if view != nil { + viewName = *view + } + return fmt.Sprintf("%s %s response %d %s %s %s", method, side, response.StatusCode, response.Tag[0], response.Tag[1], viewName) +} + +// collectResponseWireType applies the selected view and records response body +// declarations using the same policy later consumed by buildResponseBodyType. +func collectResponseWireType( + api string, + body *expr.AttributeExpr, + releasedBody *expr.AttributeExpr, + endpoint *expr.HTTPEndpointExpr, + catalog *wireTypeCatalog, + server bool, + view *string, + errorName string, +) { + body, viewName := prepareResponseWireBody(body, view) + releasedNames := releasedResponseWireNames(releasedBody, body, view) + policy := jsonBodyPolicy(false, server, !server && view == nil, viewName) + preferred := "" + if server && !expr.IsPrimitive(body.Type) && needInit(body.Type) { + if _, userType := body.Type.(expr.UserType); !userType { + preferred = codegen.Goify(endpoint.Name(), true) + "ResponseBody" + } + } + record := catalog.collectWithReleasedNames(body, wireResponseBody, policy, preferred, releasedNames, api) + if record != nil && errorName != "" { + record.addErrorUse(wireErrorUse{ + service: endpoint.Service.Name(), + method: endpoint.Name(), + name: errorName, + }) + } + if policy.validate { + catalog.addValidationRoot(body, policy) + } + if server && record != nil && needInit(body.Type) { + record.needsConstructor = true + } + attributePolicy := jsonBodyPolicy(false, server, !server, "") + catalog.collectChildrenWithReleasedNames(body, attributePolicy, releasedNames) +} + +// prepareResponseWireBody copies the response body, selects the requested view +// fields, and adds JSON tags. Collection, declaration generation, and client +// response conversion all use the returned shape. +func prepareResponseWireBody(body *expr.AttributeExpr, view *string) (*expr.AttributeExpr, string) { + body = expr.DupAtt(body) + viewName := "" + if view != nil && *view != "" { + viewName = *view + if resultType, ok := body.Type.(*expr.ResultTypeExpr); ok { + projected, err := expr.Project(resultType, *view) + if err != nil { + panic(err) + } + body.Type = projected } + } + addMarshalTags(body) + return body, viewName +} - md := sd.Service.Method(a.Name()) - for _, v := range a.Responses { - collectHTTPUnionTypes(effectiveClientResponseBody(sd.bodies.response(v), a, md), sd.Scope, unionByHash, seenUnionTypes) +// releasedResponseWireNames returns the response type names produced when Goa +// added transport suffixes before selecting a result view. +func releasedResponseWireNames(original, prepared *expr.AttributeExpr, view *string) map[expr.UserType]string { + released := expr.DupAtt(original) + suffix := releasedWireTypeSuffix(released, wireResponseBody) + if userType, ok := released.Type.(expr.UserType); ok { + appendReleasedWireSuffix(userType.Attribute().Type, suffix, make(map[expr.UserType]struct{})) + } else { + appendReleasedWireSuffix(released.Type, suffix, make(map[expr.UserType]struct{})) + } + released, _ = prepareResponseWireBody(released, view) + names := make(map[expr.UserType]string) + collectReleasedWireNames(prepared.Type, released.Type, names, make(map[releasedWireTypePair]struct{})) + if collection, ok := prepared.Type.(*expr.ResultTypeExpr); ok { + if array := expr.AsArray(collection.Attribute().Type); array != nil { + if element, ok := array.ElemType.Type.(expr.UserType); ok { + names[collection] = names[element] + "Collection" + } } + } + return names +} - for _, v := range a.HTTPErrors { - collectHTTPUnionTypes(sd.bodies.errorResponse(v), sd.Scope, unionByHash, seenUnionTypes) +// appendReleasedWireSuffix reproduces the order used by released Goa versions +// on a private copy of the response type. +func appendReleasedWireSuffix(dataType expr.DataType, suffix string, seen map[expr.UserType]struct{}) { + switch actual := dataType.(type) { + case expr.UserType: + if _, ok := seen[actual]; ok { + return + } + seen[actual] = struct{}{} + actual.Rename(actual.Name() + suffix) + appendReleasedWireSuffix(actual.Attribute().Type, suffix, seen) + case *expr.Object: + for _, named := range *actual { + appendReleasedWireSuffix(named.Attribute.Type, suffix, seen) + } + case *expr.Array: + appendReleasedWireSuffix(actual.ElemType.Type, suffix, seen) + case *expr.Map: + appendReleasedWireSuffix(actual.KeyType.Type, suffix, seen) + appendReleasedWireSuffix(actual.ElemType.Type, suffix, seen) + case *expr.Union: + for _, named := range actual.Values { + appendReleasedWireSuffix(named.Attribute.Type, suffix, seen) } } +} - unions := make([]*service.UnionTypeData, 0, len(unionByHash)) - for _, u := range unionByHash { - unions = append(unions, u) +// collectReleasedWireNames pairs the current response types with the names +// from the earlier suffix-before-view order. +func collectReleasedWireNames(current, released expr.DataType, names map[expr.UserType]string, seen map[releasedWireTypePair]struct{}) { + currentUser, currentNamed := current.(expr.UserType) + releasedUser, releasedNamed := released.(expr.UserType) + if currentNamed || releasedNamed { + if !currentNamed || !releasedNamed { + panic("response view changed whether a generated type is named") + } + pair := releasedWireTypePair{current: currentUser, released: releasedUser} + if _, ok := seen[pair]; ok { + return + } + seen[pair] = struct{}{} + names[currentUser] = codegen.Goify(releasedUser.Name(), true) + collectReleasedWireNames(currentUser.Attribute().Type, releasedUser.Attribute().Type, names, seen) + return } - sort.Slice(unions, func(i, j int) bool { - return unions[i].Name < unions[j].Name - }) - sd.UnionTypes = unions - return sd + switch currentType := current.(type) { + case *expr.Object: + releasedType, ok := released.(*expr.Object) + if !ok { + panic("response view changed the generated object shape") + } + for _, named := range *currentType { + other := releasedType.Attribute(named.Name) + if other == nil { + panic("response view changed a generated field name") + } + collectReleasedWireNames(named.Attribute.Type, other.Type, names, seen) + } + case *expr.Array: + releasedType, ok := released.(*expr.Array) + if !ok { + panic("response view changed the generated array shape") + } + collectReleasedWireNames(currentType.ElemType.Type, releasedType.ElemType.Type, names, seen) + case *expr.Map: + releasedType, ok := released.(*expr.Map) + if !ok { + panic("response view changed the generated map shape") + } + collectReleasedWireNames(currentType.KeyType.Type, releasedType.KeyType.Type, names, seen) + collectReleasedWireNames(currentType.ElemType.Type, releasedType.ElemType.Type, names, seen) + case *expr.Union: + releasedType, ok := released.(*expr.Union) + if !ok || len(currentType.Values) != len(releasedType.Values) { + panic("response view changed the generated union shape") + } + for index, named := range currentType.Values { + collectReleasedWireNames(named.Attribute.Type, releasedType.Values[index].Attribute.Type, names, seen) + } + } } // makeHTTPType traverses the attribute recursively and performs these actions: @@ -1085,10 +1958,11 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { // * changes unions into structs with Type and Value fields. func makeHTTPType(att *expr.AttributeExpr) *expr.AttributeExpr { att = expr.DupAtt(att) - return makeHTTPTypeRecursive(att, make(map[string]struct{})) + return makeHTTPTypeRecursive(att, make(map[expr.UserType]struct{})) } -func makeHTTPTypeRecursive(att *expr.AttributeExpr, seen map[string]struct{}) *expr.AttributeExpr { +func makeHTTPTypeRecursive(att *expr.AttributeExpr, seen map[expr.UserType]struct{}) *expr.AttributeExpr { + delete(att.Meta, "struct:pkg:path") switch dt := att.Type.(type) { case expr.UserType: if dt == expr.Empty { @@ -1111,10 +1985,11 @@ func makeHTTPTypeRecursive(att *expr.AttributeExpr, seen map[string]struct{}) *e att.DefaultValue = dt.Attribute().DefaultValue att.UserExamples = dt.Attribute().UserExamples } - if _, ok := seen[dt.ID()]; ok { + origin := dt.Origin() + if _, ok := seen[origin]; ok { return att } - seen[dt.ID()] = struct{}{} + seen[origin] = struct{}{} dt.SetAttribute(makeHTTPTypeRecursive(dt.Attribute(), seen)) case *expr.Array: dt.ElemType = makeHTTPTypeRecursive(dt.ElemType, seen) @@ -1150,11 +2025,10 @@ func (b *shapedBodies) request(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { return att } -// streaming returns the streaming request body for the given endpoint. The -// returned attribute is a detached copy of the design body so that marshal -// tag meta may be added to it without affecting the design expression tree. -// Streaming bodies are not shaped with makeHTTPType: aliased user types have -// never been flattened in streaming bodies. +// streaming returns a copy of the endpoint's streaming request body. Generated +// JSON field information may be added to the copy without changing the authored +// design. Streaming requests keep named user types instead of replacing them +// with their fields. func (b *shapedBodies) streaming(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { if att, ok := b.streams[e]; ok { return att @@ -1163,10 +2037,26 @@ func (b *shapedBodies) streaming(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { b.streams = make(map[*expr.HTTPEndpointExpr]*expr.AttributeExpr) } att := expr.DupAtt(e.StreamingBody) + expr.RemovePkgPath(att) b.streams[e] = att return att } +// streamingResult returns the JSON body written for each result in a mixed SSE +// stream. It copies the service result before applying HTTP field names so +// generation never changes the authored service type. +func (b *shapedBodies) streamingResult(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { + if att, ok := b.streamResults[e]; ok { + return att + } + if b.streamResults == nil { + b.streamResults = make(map[*expr.HTTPEndpointExpr]*expr.AttributeExpr) + } + att := makeHTTPType(e.MethodExpr.StreamingResult) + b.streamResults[e] = att + return att +} + // response returns the shaped HTTP body for the given success response, see // request. func (b *shapedBodies) response(resp *expr.HTTPResponseExpr) *expr.AttributeExpr { @@ -1200,27 +2090,39 @@ func (b *shapedBodies) errorResponse(v *expr.HTTPErrorExpr) *expr.AttributeExpr // used by the request body type recursively if any. func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceData) *PayloadData { httpBody := sd.bodies.request(e) + serverHTTPBody := expr.DupAtt(httpBody) + clientHTTPBody := expr.DupAtt(httpBody) + if httpBody.Type != expr.Empty { + addMarshalTags(serverHTTPBody) + addMarshalTags(clientHTTPBody) + serverPolicy := jsonBodyPolicy(true, true, true, "") + clientPolicy := jsonBodyPolicy(true, false, true, "") + sd.serverWireTypes.applyNames(serverHTTPBody, wireRequestBody, serverPolicy) + sd.clientWireTypes.applyNames(clientHTTPBody, wireRequestBody, clientPolicy) + } var ( - payload = e.MethodExpr.Payload - svc = sd.Service - body = httpBody.Type - ep = svc.Method(e.MethodExpr.Name) - httpsvrctx = httpContext(sd.Scope, true, true) - httpclictx = httpContext(sd.Scope, true, false) - pkg = ep.PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcctx = serviceContext(pkg, sd.Service.Scope) + payload = e.MethodExpr.Payload + svc = sd.Service + body = httpBody.Type + ep = svc.Method(e.MethodExpr.Name) + httpsvrctx = jsonBodyContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + httpclictx = jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) + svcsvrctx = sds.serviceTypeContext(sd, "server").Enter(payload) + svcclictx = sds.serviceTypeContext(sd, "client").Enter(payload) + payloadOwner = expr.MethodPayloadExampleIdentity(e.MethodExpr) + bodyOwner = expr.RequestBodyExampleIdentity(e) request *RequestData mapQueryParam *ParamData ) { var ( - serverBodyData = sds.buildRequestBodyType(httpBody, payload, e, true, sd) - clientBodyData = sds.buildRequestBodyType(httpBody, payload, e, false, sd) - paramsData = sds.extractPathParams(e.PathParams(), payload, sd.Scope) - queryData = sds.extractQueryParams(e.QueryParams(), payload, sd.Scope) - headersData = sds.extractHeaders(e.Headers, payload, svcctx, sd.Scope) - cookiesData = sds.extractCookies(e.Cookies, payload, svcctx, sd.Scope) + serverBodyData = sds.buildRequestBodyType(httpBody, payload, e, wireRequestBody, true, sd, payloadOwner, bodyOwner) + clientBodyData = sds.buildRequestBodyType(httpBody, payload, e, wireRequestBody, false, sd, payloadOwner, bodyOwner) + paramsData = sds.extractPathParams(e.PathParams(), payload, sd, payloadOwner) + queryData = sds.extractQueryParams(e.QueryParams(), payload, sd, payloadOwner) + headersData = sds.extractHeaders(e.Headers, payload, svcsvrctx, sd.Scope, payloadOwner) + cookiesData = sds.extractCookies(e.Cookies, payload, svcsvrctx, sd.Scope, payloadOwner) origin string mustValidate bool @@ -1240,32 +2142,36 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD fieldName = codegen.Goify(name, true) } varn := codegen.Goify(name, false) + typeName := sd.Scope.GoTypeName(pAtt) + typeRef := sd.Scope.GoTypeRef(pAtt) + validate := codegen.AttributeValidationCode(pAtt, nil, httpsvrctx, required, expr.IsAlias(pAtt.Type), varn, name) mapQueryParam = &ParamData{ MapQueryParams: e.MapQueryParams, - Map: expr.AsMap(payload.Type) != nil, Element: &Element{ HTTPName: name, AttributeData: &AttributeData{ - Name: name, - VarName: varn, - FieldName: fieldName, - FieldType: pAtt.Type, - Required: required, - Type: pAtt.Type, - TypeName: sd.Scope.GoTypeName(pAtt), - TypeRef: sd.Scope.GoTypeRef(pAtt), - Validate: codegen.AttributeValidationCode(pAtt, nil, httpsvrctx, required, expr.IsAlias(pAtt.Type), varn, name), + Name: name, + VarName: varn, + FieldName: fieldName, + FieldType: pAtt.Type, + Required: required, + Type: pAtt.Type, + TypeName: typeName, + TypeRef: typeRef, + Validate: validate, + CLIPlan: cli.NewFlagPlan( + pAtt, + typeName, + typeRef, + cliValidationRenderer(validate != "", pAtt, httpsvrctx, name), + ), DefaultValue: pAtt.DefaultValue, - Example: pAtt.Example(sds.Root.API.ExampleGenerator.Field(e.MethodExpr.Payload, name)), + Example: sds.FieldExample(pAtt, e.MethodExpr.Payload, name, payloadOwner), }, }, } queryData = append(queryData, mapQueryParam) } - if serverBodyData != nil { - sd.ServerTypeNames[serverBodyData.Name] = struct{}{} - sd.ClientTypeNames[serverBodyData.Name] = struct{}{} - } for _, p := range cookiesData { if p.Required || p.Validate != "" || needConversion(p.Type) { mustValidate = true @@ -1300,7 +2206,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD // If design uses Body("name") syntax we need to use the // corresponding attribute in the result type for body // transformation. - if o, ok := httpBody.Meta["origin:attribute"]; ok { + if o, ok := serverHTTPBody.Meta["origin:attribute"]; ok { origin = o[0] if !payload.IsRequired(o[0]) { mustHaveBody = false @@ -1334,16 +2240,11 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD serverArgs []*InitArgData ) argsCap := len(request.PathParams) + len(request.QueryParams) + len(request.Headers) + len(request.Cookies) - n := codegen.Goify(ep.Name, true) - p := codegen.Goify(ep.Payload, true) - // Raw payload object has type name prefixed with endpoint name. No need to - // prefix the type name again. - if strings.HasPrefix(p, n) { - p = svc.Scope.HashedUnique(payload.Type, p) - name = fmt.Sprintf("New%s", p) - } else { - name = fmt.Sprintf("New%s%s", n, p) + declaration := sds.payloadConstructors[e] + if declaration == nil { + panic(fmt.Sprintf("payload constructor for %s.%s was not submitted", svc.Name, e.Name())) } + name = declaration.Name() desc = fmt.Sprintf("%s builds a %s service %s endpoint payload.", name, svc.Name, e.Name()) isObject = expr.IsObject(payload.Type) @@ -1351,39 +2252,68 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD clientArgs = make([]*InitArgData, 0, argsCap+1) if body != expr.Empty { var ( - svcode string - cvcode string + svcode string + cvcode string + serverTypeName string + serverTypeRef string + clientTypeName string + clientTypeRef string ) - if ut, ok := body.(expr.UserType); ok { + if record := sd.serverWireTypes.lookupUser(serverHTTPBody, wireRequestBody, jsonBodyPolicy(true, true, true, "")); record != nil { + serverTypeName = record.name + serverTypeRef = record.ref + } else { + serverTypeName = httpsvrctx.Scope.Name(serverHTTPBody, "", httpsvrctx.Pointer, httpsvrctx.UseDefault) + serverTypeRef = httpsvrctx.Scope.Ref(serverHTTPBody, "") + } + if record := sd.clientWireTypes.lookupUser(clientHTTPBody, wireRequestBody, jsonBodyPolicy(true, false, true, "")); record != nil { + clientTypeName = record.name + clientTypeRef = record.ref + } else { + clientTypeName = httpclictx.Scope.Name(clientHTTPBody, "", httpclictx.Pointer, httpclictx.UseDefault) + clientTypeRef = httpclictx.Scope.Ref(clientHTTPBody, "") + } + if ut, ok := serverHTTPBody.Type.(expr.UserType); ok { if val := ut.Attribute().Validation; val != nil { svcode = codegen.ValidationCode(ut.Attribute(), ut, httpsvrctx, true, expr.IsAlias(ut), false, "body") + } + } + if ut, ok := clientHTTPBody.Type.(expr.UserType); ok { + if val := ut.Attribute().Validation; val != nil { cvcode = codegen.ValidationCode(ut.Attribute(), ut, httpclictx, true, expr.IsAlias(ut), false, "body") } } + cliValidation := cliValidationRenderer(cvcode != "", clientHTTPBody, httpclictx, "body") serverArgs = append(serverArgs, &InitArgData{ - Ref: sd.Scope.GoVar("body", body), + Ref: sd.serverWireTypes.scope.GoVar("body", serverHTTPBody.Type), AttributeData: &AttributeData{ Name: "body", VarName: "body", - TypeName: sd.Scope.GoTypeName(httpBody), - TypeRef: sd.Scope.GoTypeRef(httpBody), - Type: body, + TypeName: serverTypeName, + TypeRef: serverTypeRef, + Type: serverHTTPBody.Type, Required: true, - Example: httpBody.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(httpBody, bodyOwner), Validate: svcode, }, }) clientArgs = append(clientArgs, &InitArgData{ - Ref: sd.Scope.GoVar("body", body), + Ref: sd.clientWireTypes.scope.GoVar("body", clientHTTPBody.Type), AttributeData: &AttributeData{ Name: "body", VarName: "body", - TypeName: sd.Scope.GoTypeNameWithDefaults(httpBody), - TypeRef: sd.Scope.GoTypeRefWithDefaults(httpBody), - Type: body, + TypeName: clientTypeName, + TypeRef: clientTypeRef, + Type: clientHTTPBody.Type, Required: true, - Example: httpBody.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(httpBody, bodyOwner), Validate: cvcode, + CLIPlan: cli.NewFlagPlan( + clientHTTPBody, + clientTypeName, + clientTypeName, + cliValidation, + ), }, }) } @@ -1423,7 +2353,10 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD for _, sc := range r.Schemes { if sc.Type == "Basic" { uatt := e.MethodExpr.Payload.Find(sc.UsernameAttr) - uref := svc.Scope.GoTypeRef(uatt) + uctx := svcclictx.Enter(uatt) + uref := uctx.Scope.Ref(uatt, uctx.Pkg(uatt)) + uvalueRef := uref + uvalidate := codegen.ValidationCode(uatt, nil, httpsvrctx, sc.UsernameRequired, expr.IsAlias(uatt.Type), false, sc.UsernameAttr) if sc.UsernamePointer { uref = "*" + uref } @@ -1437,16 +2370,25 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD FieldType: uatt.Type, Description: uatt.Description, Required: sc.UsernameRequired, - TypeName: svc.Scope.GoTypeName(uatt), + TypeName: uctx.Scope.Name(uatt, uctx.Pkg(uatt), false, true), TypeRef: uref, Type: uatt.Type, Pointer: sc.UsernamePointer, - Validate: codegen.ValidationCode(uatt, nil, httpsvrctx, sc.UsernameRequired, expr.IsAlias(uatt.Type), false, sc.UsernameAttr), - Example: uatt.Example(sds.Root.API.ExampleGenerator.Field(e.MethodExpr.Payload, sc.UsernameAttr)), + Validate: uvalidate, + CLIPlan: cli.NewFlagPlan( + uatt, + uctx.Scope.Name(uatt, uctx.Pkg(uatt), false, true), + uvalueRef, + cliValidationRenderer(uvalidate != "", uatt, uctx, sc.UsernameAttr), + ), + Example: sds.FieldExample(uatt, e.MethodExpr.Payload, sc.UsernameAttr, payloadOwner), }, } patt := e.MethodExpr.Payload.Find(sc.PasswordAttr) - pref := svc.Scope.GoTypeRef(patt) + pctx := svcclictx.Enter(patt) + pref := pctx.Scope.Ref(patt, pctx.Pkg(patt)) + pvalueRef := pref + pvalidate := codegen.ValidationCode(patt, nil, httpsvrctx, sc.PasswordRequired, expr.IsAlias(patt.Type), false, sc.PasswordAttr) if sc.PasswordPointer { pref = "*" + pref } @@ -1460,12 +2402,18 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD FieldType: patt.Type, Description: patt.Description, Required: sc.PasswordRequired, - TypeName: svc.Scope.GoTypeName(patt), + TypeName: pctx.Scope.Name(patt, pctx.Pkg(patt), false, true), TypeRef: pref, Type: patt.Type, Pointer: sc.PasswordPointer, - Validate: codegen.ValidationCode(patt, nil, httpsvrctx, sc.PasswordRequired, expr.IsAlias(patt.Type), false, sc.PasswordAttr), - Example: patt.Example(sds.Root.API.ExampleGenerator.Field(e.MethodExpr.Payload, sc.PasswordAttr)), + Validate: pvalidate, + CLIPlan: cli.NewFlagPlan( + patt, + pctx.Scope.Name(patt, pctx.Pkg(patt), false, true), + pvalueRef, + cliValidationRenderer(pvalidate != "", patt, pctx, sc.PasswordAttr), + ), + Example: sds.FieldExample(patt, e.MethodExpr.Payload, sc.PasswordAttr, payloadOwner), }, } cliArgs = []*InitArgData{uarg, parg} @@ -1484,60 +2432,72 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD err error origin string pointer bool - - pAtt = payload ) + requestTransforms := sd.transforms.requests[clientBodyConstructorKey{endpoint: e, role: wireRequestBody}] if body != expr.Empty { // If design uses Body("name") syntax then need to use payload // attribute to transform. if o, ok := httpBody.Meta["origin:attribute"]; ok { origin = o[0] - pAtt = expr.AsObject(payload.Type).Attribute(origin) - pointer = !payload.IsRequired(o[0]) && expr.IsPrimitive(pAtt.Type) + attribute := expr.AsObject(payload.Type).Attribute(origin) + pointer = !payload.IsRequired(o[0]) && expr.IsPrimitive(attribute.Type) } var ( helpers []*codegen.TransformFunctionData ) - serverCode, helpers, err = unmarshal(httpBody, pAtt, "body", httpsvrctx, svcctx) + transformctx := jsonBodyContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + serverCode, helpers, err = sd.serverWireTypes.renderTransform(requestTransforms.serverDecode, serverHTTPBody, "body", "v", transformctx, svcsvrctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) + } else { + sds.recordLinkError(err) } // The client code for building the method payload from a request // body is used by the CLI tool to build the payload given to the // client endpoint. It differs because the body type there does not // use pointers for all fields (no need to validate). - clientCode, helpers, err = marshal(httpBody, pAtt, "body", "v", httpclictx, svcctx) + transformctx = jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) + clientCode, helpers, err = sd.clientWireTypes.renderTransform(requestTransforms.clientDecode, clientHTTPBody, "body", "v", transformctx, svcclictx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } else { + sds.recordLinkError(err) } } else if expr.IsArray(payload.Type) || expr.IsMap(payload.Type) { if params := expr.AsObject(e.Params.Type); len(*params) > 0 { var helpers []*codegen.TransformFunctionData - serverCode, helpers, err = unmarshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), httpsvrctx, svcctx) + transformctx := wireHTTPContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + serverCode, helpers, err = sd.serverWireTypes.renderTransform(requestTransforms.serverDecode, (*params)[0].Attribute, codegen.Goify((*params)[0].Name, false), "v", transformctx, svcsvrctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) + } else { + sds.recordLinkError(err) } - clientCode, helpers, err = marshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), "v", httpclictx, svcctx) + transformctx = wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) + clientCode, helpers, err = sd.clientWireTypes.renderTransform(requestTransforms.clientDecode, (*params)[0].Attribute, codegen.Goify((*params)[0].Name, false), "v", transformctx, svcclictx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } else { + sds.recordLinkError(err) } } } if err != nil { - panic(err) // bug + sds.recordLinkError(err) } init = &InitData{ + Declaration: declaration, Name: name, Description: desc, ServerArgs: serverArgs, ClientArgs: clientArgs, CLIArgs: cliArgs, - ReturnTypeName: svc.Scope.GoFullTypeName(payload, pkg), - ReturnTypeRef: svc.Scope.GoFullTypeRef(payload, pkg), + ReturnTypeName: svcsvrctx.Scope.Name(payload, svcsvrctx.Pkg(payload), false, true), + ReturnTypeRef: svcsvrctx.Scope.Ref(payload, svcsvrctx.Pkg(payload)), ReturnIsStruct: isObject, ReturnTypeAttribute: codegen.Goify(origin, true), - ReturnTypePkg: pkg, + ReturnTypePkg: svcsvrctx.Pkg(payload), ServerCode: serverCode, ClientCode: clientCode, ReturnIsPrimitivePointer: pointer, @@ -1549,10 +2509,12 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD returnValue string name string ref string + cliPlan *cli.FlagPlan ) if payload.Type != expr.Empty { - name = svc.Scope.GoFullTypeName(payload, pkg) - ref = svc.Scope.GoFullTypeRef(payload, pkg) + name = svcsvrctx.Scope.Name(payload, svcsvrctx.Pkg(payload), false, true) + ref = svcsvrctx.Scope.Ref(payload, svcsvrctx.Pkg(payload)) + cliPlan = cli.NewFlagPlan(payload, name, ref, nil) } if init == nil { if o := expr.AsObject(e.Params.Type); o != nil && len(*o) > 0 { @@ -1568,6 +2530,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD data := &PayloadData{ Name: name, Ref: ref, + CLIPlan: cliPlan, Request: request, DecoderReturnValue: returnValue, } @@ -1589,10 +2552,9 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD // buildResultData builds the result data for the given service endpoint. func (sds *ServicesData) buildResultData(e *expr.HTTPEndpointExpr, sd *ServiceData) *ResultData { var ( - svc = sd.Service - ep = svc.Method(e.MethodExpr.Name) - pkg = ep.ResultLoc.PackageNameOrDefault(svc.PkgName) result = e.MethodExpr.Result + method = sd.Service.Method(e.MethodExpr.Name) + svcctx = sds.serviceTypeContext(sd, "server").Enter(result) name string ref string @@ -1604,8 +2566,8 @@ func (sds *ServicesData) buildResultData(e *expr.HTTPEndpointExpr, sd *ServiceDa view = v } if result.Type != expr.Empty { - name = svc.Scope.GoFullTypeName(result, pkg) - ref = svc.Scope.GoFullTypeRef(result, pkg) + name = svcctx.Scope.Name(result, svcctx.Pkg(result), false, true) + ref = svcctx.Scope.Ref(result, svcctx.Pkg(result)) } var ( @@ -1614,8 +2576,8 @@ func (sds *ServicesData) buildResultData(e *expr.HTTPEndpointExpr, sd *ServiceDa ) { viewed := false - if ep.ViewedResult != nil { - result = expr.AsObject(ep.ViewedResult.Type).Attribute("projected") + if method.ViewedResult != nil { + result = expr.AsObject(method.ViewedResult.Type).Attribute("projected") viewed = true } responses = sds.buildResponses(e, result, viewed, sd) @@ -1661,21 +2623,21 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A var ( responses []*ResponseData - svc = sd.Service - md = svc.Method(e.Name()) - pkg = md.ResultLoc.PackageNameOrDefault(svc.PkgName) - httpclictx = httpContext(sd.Scope, false, false) - scope = svc.Scope - svcctx = serviceContext(pkg, sd.Service.Scope) + svc = sd.Service + md = svc.Method(e.Name()) + scope = svc.Scope + svcctx = sds.serviceTypeContext(sd, "client").Enter(result) ) { if viewed { scope = svc.ViewScope - svcctx = viewContext(sd.Service.ViewsPkg, sd.Service.ViewScope) + svcctx = sds.viewTypeContext(sd, "client").Enter(result) } notag := -1 for i, resp := range e.Responses { respBody := sd.bodies.response(resp) + resultOwner := expr.MethodResultExampleIdentity(e.MethodExpr) + bodyOwner := expr.ResponseBodyExampleIdentity(e, resp) if resp.Tag[0] == "" { if notag > -1 { continue // we don't want more than one response with no tag @@ -1691,12 +2653,13 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A origin string mustValidate bool clientRespBody = respBody + clientBodyView *string resAttr = result ) { - headersData = sds.extractHeaders(resp.Headers, result, svcctx, scope) - cookiesData = sds.extractCookies(resp.Cookies, result, svcctx, scope) + headersData = sds.extractHeaders(resp.Headers, result, svcctx, scope, resultOwner) + cookiesData = sds.extractCookies(resp.Cookies, result, svcctx, scope, resultOwner) if respBody.Type != expr.Empty { // If design uses Body("name") syntax we need to use the // corresponding attribute in the result type for body @@ -1712,14 +2675,16 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A if origin != "" { // Response body is explicitly set to an attribute in the method // result type. No need to do any view-based projections server side. - if sbd := sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, true, &vname, sd); sbd != nil { + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: vname}] + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &vname, sd, transforms, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } else if v, ok := e.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { // Design explicitly sets the view to render the result. // We generate only one server body type which will be rendered // using the specified view. - if sbd := sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, true, &v, sd); sbd != nil { + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: v}] + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &v, sd, transforms, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } else { @@ -1732,25 +2697,35 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A // attributes defined in the view in the response (NOTE: a required // attribute in the result type may not be present in all its views) for _, view := range md.ViewedResult.Views { - if sbd := sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, true, &view.Name, sd); sbd != nil { + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: view.Name}] + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &view.Name, sd, transforms, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } } - if clientView != "" { - clientRespBody = effectiveClientResponseBody(respBody, e, md) - clientBodyData = sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, false, &clientView, sd) - } else { - clientBodyData = sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, false, &vname, sd) + switch { + case clientView != "": + clientRespBody = effectiveClientResponseBodyForView(respBody, clientView) + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &clientView, sd, nil, resultOwner, bodyOwner) + clientBodyView = &clientView + case origin != "" || !e.UsesSSE() && !e.IsJSONRPC(): + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &vname, sd, nil, resultOwner, bodyOwner) + clientBodyView = &vname + default: + clientRespBody = &expr.AttributeExpr{Type: expr.Empty} } } else { - if sbd := sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, true, nil, sd); sbd != nil { + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp}] + if sbd := sds.buildResponseBodyType(respBody, result, e, true, nil, sd, transforms, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } - clientBodyData = sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, false, nil, sd) + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, nil, sd, nil, resultOwner, bodyOwner) } - if clientBodyData != nil && clientBodyData.Def != "" { - sd.ClientTypeNames[clientBodyData.Name] = struct{}{} + if clientBodyData != nil && clientRespBody.Type != expr.Empty { + var viewName string + clientRespBody, viewName = prepareResponseWireBody(clientRespBody, clientBodyView) + policy := jsonBodyPolicy(false, false, clientBodyView == nil, viewName) + sd.clientWireTypes.applyNames(clientRespBody, wireResponseBody, policy) } for _, h := range headersData { if h.Validate != "" || h.Required || needConversion(h.Type) { @@ -1764,106 +2739,78 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A break } } - if needInit(result.Type) { - // generate constructor function to transform response body, - // headers and cookies into the method result type - var ( - name string - desc string - code string - tname string - tref string - err error - pointer bool - clientArgs []*InitArgData - helpers []*codegen.TransformFunctionData + variableWire := viewed && origin == "" && clientResponseViewName(e, md) == "" && (e.UsesSSE() || e.IsJSONRPC()) + if needInit(result.Type) && !variableWire { + init = sds.buildResponseResultInit( + e, resp, result, clientRespBody, origin, + headersData, cookiesData, sd, "", clientBodyData, ) - { - tname = svc.Scope.GoFullTypeName(result, pkg) - tref = svc.Scope.GoFullTypeRef(result, pkg) - if viewed { - tname = svc.ViewScope.GoFullTypeName(result, svc.ViewsPkg) - tref = svc.ViewScope.GoFullTypeRef(result, svc.ViewsPkg) + } + + var representations []*ViewedRepresentationData + if viewed && (e.UsesSSE() || e.IsJSONRPC()) { + clientView := clientResponseViewName(e, md) + if origin != "" { + views := md.ViewedResult.Views + if clientView != "" { + views = []*service.ViewData{{Name: clientView}} } - status := codegen.Goify(http.StatusText(resp.StatusCode), true) - n := codegen.Goify(md.Name, true) - r := codegen.Goify(md.Result, true) - // Raw result object has type name prefixed with endpoint name. No need to - // prefix the type name again. - if strings.HasPrefix(r, n) { - r = scope.HashedUnique(result.Type, r) - name = fmt.Sprintf("New%s%s", r, status) - } else { - name = fmt.Sprintf("New%s%s%s", n, r, status) + for _, view := range views { + representation := &ViewedRepresentationData{ + View: view.Name, + ResultAttr: codegen.Goify(origin, true), + ClientBody: clientBodyData, + ClientDataPointer: clientSSEDataPointer(e, clientRespBody), + ResultInit: init, + } + if len(serverBodyData) > 0 { + representation.ServerBody = serverBodyData[0] + } + representations = append(representations, representation) } - desc = fmt.Sprintf("%s builds a %q service %q endpoint result from a HTTP %q response.", name, svc.Name, e.Name(), status) - if clientRespBody.Type != expr.Empty { - if origin != "" { - pointer = result.IsPrimitivePointer(origin, true) + } else { + if clientView != "" { + representation := &ViewedRepresentationData{ + View: clientView, + ResultAttr: codegen.Goify(origin, true), + ClientBody: clientBodyData, + ClientDataPointer: clientSSEDataPointer(e, clientRespBody), + ResultInit: init, + } + if len(serverBodyData) > 0 { + representation.ServerBody = viewedServerBody(serverBodyData, clientView) } - ref := "body" - if expr.IsObject(clientRespBody.Type) { - ref = "&body" - pointer = false + representations = append(representations, representation) + } + for _, view := range md.ViewedResult.Views { + if clientView != "" { + break } - var vcode string - if ut, ok := clientRespBody.Type.(expr.UserType); ok { - if val := ut.Attribute().Validation; val != nil { - vcode = codegen.ValidationCode(ut.Attribute(), ut, httpclictx, true, expr.IsAlias(ut), false, "body") - } + viewName := view.Name + body := effectiveClientResponseBodyForView(respBody, viewName) + clientBody := sds.buildResponseBodyType( + respBody, result, e, false, &viewName, sd, nil, resultOwner, bodyOwner, + ) + if body.Type != expr.Empty { + policy := jsonBodyPolicy(false, false, false, viewName) + sd.clientWireTypes.applyNames(body, wireResponseBody, policy) } - clientArgs = []*InitArgData{{ - Ref: ref, - AttributeData: &AttributeData{ - Name: "body", - VarName: "body", - TypeRef: sd.Scope.GoTypeRef(clientRespBody), - Validate: vcode, - }, - }} - // If the method result is a - // * result type - we unmarshal the client response body to the - // corresponding type in the views package so that view-specific - // validation logic can be applied. - // * user type - we unmarshal the client response body to the - // corresponding type in the service package after validating the - // response body. Here, the transformation code must - // rely on the fact that the required attributes are - // set in the response body (otherwise validation - // would fail). - code, helpers, err = unmarshal(clientRespBody, resAttr, "body", httpclictx, svcctx) - if err == nil { - sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + resultInit := sds.buildResponseResultInit( + e, resp, result, body, origin, + headersData, cookiesData, sd, viewName, clientBody, + ) + representation := &ViewedRepresentationData{ + View: viewName, + ResultAttr: codegen.Goify(origin, true), + ClientBody: clientBody, + ClientDataPointer: clientSSEDataPointer(e, body), + ResultInit: resultInit, } - } else if expr.IsArray(result.Type) || expr.IsMap(result.Type) { - if params := expr.AsObject(e.QueryParams().Type); len(*params) > 0 { - code, helpers, err = unmarshal((*params)[0].Attribute, result, codegen.Goify((*params)[0].Name, false), httpclictx, svcctx) - if err == nil { - sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) - } + if len(serverBodyData) > 0 { + representation.ServerBody = viewedServerBody(serverBodyData, viewName) } + representations = append(representations, representation) } - if err != nil { - panic(err) // bug - } - for _, h := range headersData { - clientArgs = append(clientArgs, resultInitArg(h.Element)) - } - for _, c := range cookiesData { - clientArgs = append(clientArgs, resultInitArg(c.Element)) - } - } - init = &InitData{ - Name: name, - Description: desc, - ClientArgs: clientArgs, - ReturnTypeName: tname, - ReturnTypeRef: tref, - ReturnIsStruct: expr.IsObject(result.Type), - ReturnTypeAttribute: codegen.Goify(origin, true), - ReturnTypePkg: pkg, - ReturnIsPrimitivePointer: pointer, - ClientCode: code, } } @@ -1878,20 +2825,21 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A tagPtr = viewed || result.IsPrimitivePointer(resp.Tag[0], true) } responses = append(responses, &ResponseData{ - StatusCode: statusCodeToHTTPConst(resp.StatusCode), - Description: resp.Description, - Headers: headersData, - Cookies: cookiesData, - ContentType: resp.ContentType, - ServerBody: serverBodyData, - ClientBody: clientBodyData, - ResultInit: init, - TagName: tagName, - TagValue: tagVal, - TagPointer: tagPtr, - MustValidate: mustValidate, - ResultAttr: codegen.Goify(origin, true), - ViewedResult: md.ViewedResult, + StatusCode: statusCodeToHTTPConst(resp.StatusCode), + Description: resp.Description, + Headers: headersData, + Cookies: cookiesData, + ContentType: resp.ContentType, + ServerBody: serverBodyData, + ClientBody: clientBodyData, + ResultInit: init, + TagName: tagName, + TagValue: tagVal, + TagPointer: tagPtr, + MustValidate: mustValidate, + ResultAttr: codegen.Goify(origin, true), + ViewedResult: md.ViewedResult, + ViewedRepresentations: representations, }) } } @@ -1904,26 +2852,126 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A return responses } +// buildResponseResultInit builds the data used to write one client result +// function. It uses the name chosen by NewPlans and converts the decoded HTTP +// body, headers, and cookies into the method result. +func (sds *ServicesData) buildResponseResultInit(e *expr.HTTPEndpointExpr, resp *expr.HTTPResponseExpr, result, clientBody *expr.AttributeExpr, origin string, headers []*HeaderData, cookies []*CookieData, sd *ServiceData, view string, bodyType *TypeData) *InitData { + var ( + svc = sd.Service + md = svc.Method(e.Name()) + svcctx = sds.serviceTypeContext(sd, "client").Enter(result) + ) + if md.ViewedResult != nil { + svcctx = sds.viewTypeContext(sd, "client").Enter(result) + } + tname := svcctx.Scope.Name(result, svcctx.Pkg(result), false, true) + tref := svcctx.Scope.Ref(result, svcctx.Pkg(result)) + status := codegen.Goify(http.StatusText(resp.StatusCode), true) + declaration := sds.viewedResultConstructors[viewedConstructorKey{endpoint: e, response: resp, view: view}] + if declaration == nil { + panic(fmt.Sprintf("result constructor for %s.%s view %q was not submitted", svc.Name, e.Name(), view)) + } + name := declaration.Name() + desc := fmt.Sprintf("%s builds a %q service %q endpoint result from a HTTP %q response.", name, svc.Name, e.Name(), status) + + var ( + code string + pointer bool + clientArgs = make([]*InitArgData, 0, len(headers)+len(cookies)+1) + ) + if clientBody.Type != expr.Empty { + if origin != "" { + pointer = svcctx.IsPrimitivePointer(origin, result) + } + ref := "body" + if expr.IsObject(clientBody.Type) { + ref = "&body" + pointer = false + } + bodyTypeRef := bodyType.Ref + if bodyTypeRef == "" { + bodyTypeRef = bodyType.VarName + } + clientArgs = append(clientArgs, &InitArgData{ + Ref: ref, + AttributeData: &AttributeData{ + Name: "body", + VarName: "body", + TypeRef: bodyTypeRef, + }, + }) + transformctx := jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + bodyPolicy := wireTypePolicy{ + pointer: transformctx.Pointer, + arrayElementPointer: transformctx.ArrayElementPointer, + view: bodyType.View, + } + transformctx.Scope = sd.clientWireTypes.resolver(sd.clientWireTypes.scope, bodyPolicy) + if bodyPolicy.view != "" { + transformctx.Scope = sd.clientWireTypes.rootResolver(sd.clientWireTypes.scope, bodyPolicy, bodyType.declaration) + } + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: bodyType.View}] + converted, helpers, err := sd.clientWireTypes.renderTransform(transforms.clientDecode, clientBody, "body", "v", transformctx, svcctx) + if err != nil { + sds.recordLinkError(err) + } + code = converted + sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } else if expr.IsArray(result.Type) || expr.IsMap(result.Type) { + if params := expr.AsObject(e.QueryParams().Type); len(*params) > 0 { + queryctx := wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: view}] + converted, helpers, err := sd.clientWireTypes.renderTransform(transforms.clientDecode, (*params)[0].Attribute, codegen.Goify((*params)[0].Name, false), "v", queryctx, svcctx) + if err != nil { + sds.recordLinkError(err) + } + code = converted + sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } + } + for _, header := range headers { + clientArgs = append(clientArgs, resultInitArg(header.Element)) + } + for _, cookie := range cookies { + clientArgs = append(clientArgs, resultInitArg(cookie.Element)) + } + return &InitData{ + Declaration: declaration, + Name: name, + Description: desc, + ClientArgs: clientArgs, + ReturnTypeName: tname, + ReturnTypeRef: tref, + ReturnIsStruct: expr.IsObject(result.Type), + ReturnTypeAttribute: codegen.Goify(origin, true), + ReturnTypePkg: svcctx.Pkg(result), + ReturnIsPrimitivePointer: pointer, + ClientCode: code, + } +} + // buildErrorsData builds the error data for all the error responses in the // endpoint expression. The response headers, cookies and body for each response // are inferred from the method's error expression if not specified explicitly. func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceData) []*ErrorGroupData { var ( svc = sd.Service - ep = svc.Method(e.MethodExpr.Name) - httpclictx = httpContext(sd.Scope, false, false) + httpclictx = jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) ) data := make(map[string][]*ErrorData) for _, v := range e.HTTPErrors { - respBody := sd.bodies.errorResponse(v) + respBody := expr.DupAtt(sd.bodies.errorResponse(v)) + addMarshalTags(respBody) + errorAttribute := e.MethodExpr.Error(v.Name).AttributeExpr + errorOwner := expr.MethodErrorExampleIdentity(e.MethodExpr, v.ErrorExpr) + bodyOwner := expr.ErrorResponseBodyExampleIdentity(e, v) var ( init *InitData body = respBody.Type ) - pkg := ep.ErrorLocs[v.Name].PackageNameOrDefault(svc.PkgName) - errctx := serviceContext(pkg, sd.Service.Scope) + errctx := sds.serviceTypeContext(sd, "client").Enter(errorAttribute) if needInit(v.Type) { var ( @@ -1932,11 +2980,15 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa isObject bool args []*InitArgData ) - name = fmt.Sprintf("New%s%s", codegen.Goify(ep.Name, true), codegen.Goify(v.ErrorExpr.Name, true)) + declaration := sds.errorConstructors[v] + if declaration == nil { + panic(fmt.Sprintf("error constructor for %s.%s error %q was not submitted", svc.Name, e.Name(), v.Name)) + } + name = declaration.Name() desc = fmt.Sprintf("%s builds a %s service %s endpoint %s error.", name, svc.Name, e.Name(), v.ErrorExpr.Name) - headers := sds.extractHeaders(v.Response.Headers, v.AttributeExpr, errctx, sd.Scope) - cookies := sds.extractCookies(v.Response.Cookies, v.AttributeExpr, errctx, sd.Scope) + headers := sds.extractHeaders(v.Response.Headers, errorAttribute, errctx, sd.Scope, errorOwner) + cookies := sds.extractCookies(v.Response.Cookies, errorAttribute, errctx, sd.Scope, errorOwner) argsCap := len(headers) + len(cookies) if body != expr.Empty { argsCap++ @@ -1948,9 +3000,18 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa if isObject { ref = "&body" } + policy := jsonBodyPolicy(false, false, true, "") + bodyRecord := sd.clientWireTypes.lookupUser(respBody, wireResponseBody, policy) + sd.clientWireTypes.applyNames(respBody, wireResponseBody, policy) + var bodyTypeRef string + if bodyRecord != nil { + bodyTypeRef = bodyRecord.ref + } else { + bodyTypeRef = httpclictx.Scope.Ref(respBody, "") + } args = append(args, &InitArgData{ Ref: ref, - AttributeData: &AttributeData{Name: "body", VarName: "body", TypeRef: sd.Scope.GoTypeRef(respBody)}, + AttributeData: &AttributeData{Name: "body", VarName: "body", TypeRef: bodyTypeRef}, }) } for _, h := range headers { @@ -1966,41 +3027,44 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa err error ) if body != expr.Empty { - eAtt := v.AttributeExpr // If design uses Body("name") syntax then need to use payload // attribute to transform. if o, ok := respBody.Meta["origin:attribute"]; ok { origin = o[0] - eAtt = expr.AsObject(v.ErrorExpr.Type).Attribute(origin) } var helpers []*codegen.TransformFunctionData - code, helpers, err = unmarshal(respBody, eAtt, "body", httpclictx, errctx) + transformctx := jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transforms := sd.transforms.errors[v] + code, helpers, err = sd.clientWireTypes.renderTransform(transforms.clientDecode, respBody, "body", "v", transformctx, errctx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } } else if expr.IsArray(v.Type) || expr.IsMap(v.Type) { if params := expr.AsObject(e.QueryParams().Type); len(*params) > 0 { var helpers []*codegen.TransformFunctionData - code, helpers, err = unmarshal((*params)[0].Attribute, v.AttributeExpr, codegen.Goify((*params)[0].Name, false), httpclictx, errctx) + queryctx := wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transforms := sd.transforms.errors[v] + code, helpers, err = sd.clientWireTypes.renderTransform(transforms.clientDecode, (*params)[0].Attribute, codegen.Goify((*params)[0].Name, false), "v", queryctx, errctx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } } } if err != nil { - panic(err) // bug + sds.recordLinkError(err) } init = &InitData{ + Declaration: declaration, Name: name, Description: desc, ClientArgs: args, - ReturnTypeName: svc.Scope.GoFullTypeName(v.AttributeExpr, pkg), - ReturnTypeRef: svc.Scope.GoFullTypeRef(v.AttributeExpr, pkg), + ReturnTypeName: errctx.Scope.Name(errorAttribute, errctx.Pkg(errorAttribute), false, true), + ReturnTypeRef: errctx.Scope.Ref(errorAttribute, errctx.Pkg(errorAttribute)), ReturnIsStruct: expr.IsObject(v.Type), ReturnTypeAttribute: codegen.Goify(origin, true), - ReturnTypePkg: pkg, + ReturnTypePkg: errctx.Pkg(errorAttribute), ClientCode: code, } } @@ -2014,24 +3078,15 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa clientBodyData *TypeData ) { - errorLoc := ep.ErrorLocs[v.ErrorExpr.Name] - if sbd := sds.buildResponseBodyType(respBody, v.AttributeExpr, errorLoc, e, true, nil, sd); sbd != nil { + transforms := sd.transforms.errors[v] + if sbd := sds.buildResponseBodyType(respBody, errorAttribute, e, true, nil, sd, transforms, errorOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } - clientBodyData = sds.buildResponseBodyType(respBody, v.AttributeExpr, errorLoc, e, false, nil, sd) - if clientBodyData != nil { - if clientBodyData.Def != "" { - sd.ClientTypeNames[clientBodyData.Name] = struct{}{} - } - clientBodyData.Description = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body for the %q error.", - clientBodyData.VarName, svc.Name, e.Name(), v.Name) - serverBodyData[0].Description = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body for the %q error.", - serverBodyData[0].VarName, svc.Name, e.Name(), v.Name) - } + clientBodyData = sds.buildResponseBodyType(respBody, errorAttribute, e, false, nil, sd, nil, errorOwner, bodyOwner) } - headers := sds.extractHeaders(v.Response.Headers, v.AttributeExpr, errctx, sd.Scope) - cookies := sds.extractCookies(v.Response.Cookies, v.AttributeExpr, errctx, sd.Scope) + headers := sds.extractHeaders(v.Response.Headers, errorAttribute, errctx, sd.Scope, errorOwner) + cookies := sds.extractCookies(v.Response.Cookies, errorAttribute, errctx, sd.Scope, errorOwner) var mustValidate bool for _, h := range headers { if h.Validate != "" || h.Required || needConversion(h.Type) { @@ -2063,7 +3118,7 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa } } - ref := svc.Scope.GoFullTypeRef(v.AttributeExpr, pkg) + ref := errctx.Scope.Ref(errorAttribute, errctx.Pkg(errorAttribute)) data[ref] = append(data[ref], &ErrorData{ Name: v.Name, Response: responseData, @@ -2114,46 +3169,65 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa // svr is true if the function is generated for server side code. // // sd is the service data -func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e *expr.HTTPEndpointExpr, svr bool, sd *ServiceData) *TypeData { +func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e *expr.HTTPEndpointExpr, role wireTypeRole, svr bool, sd *ServiceData, sourceOwner, bodyOwner expr.ExampleIdentity) *TypeData { if body.Type == expr.Empty { return nil } + body = expr.DupAtt(body) var ( - name string - varname string - desc string - def string - ref string - validateDef string - validateRef string + name string + varname string + desc string + def string + ref string + validateDef string + nestedValidateDef string + validateRef string + validationTarget string svc = sd.Service - httpctx = httpContext(sd.Scope, true, svr) - ep = sd.Service.Method(e.Name()) - pkg = ep.PayloadLoc.PackageNameOrDefault(sd.Service.PkgName) - svcctx = serviceContext(pkg, sd.Service.Scope) + catalog = sd.wireTypes(svr) + policy = jsonBodyPolicy(true, svr, true, "") + httpctx = jsonBodyContext(catalog, catalog.scope, true, svr) + side = "client" ) + if svr { + side = "server" + } + svcctx := sds.serviceTypeContext(sd, side).Enter(att) + addMarshalTags(body) + record := catalog.lookupUser(body, role, policy) + catalog.applyNames(body, role, policy) name = body.Type.Name() - ref = sd.Scope.GoTypeRef(body) - - addMarshalTags(body, make(map[string]struct{})) + if record != nil { + name = record.name + ref = record.ref + } else { + ref = httpctx.Scope.Ref(body, "") + } if ut, ok := body.Type.(expr.UserType); ok { - varname = codegen.Goify(ut.Name(), true) - def = goTypeDef(sd.Scope, ut.Attribute(), svr, !svr) + varname = record.name + def = goTypeDefForContext(ut.Attribute(), httpctx) desc = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP request body.", varname, svc.Name, e.Name()) if svr { // generate validation code for unmarshaled type (server-side). validateDef = codegen.ValidationCode(ut.Attribute(), ut, httpctx, true, expr.IsAlias(ut), false, "body") + if record.needsNestedCall { + nestedValidateDef = codegen.ValidationCodeWithPathParameter(ut.Attribute(), ut, httpctx, true, expr.IsAlias(ut), false, "body", "path") + } if validateDef != "" { - validateRef = fmt.Sprintf("err = Validate%s(&body)", varname) + validationTarget = "&body" } } } else { - // Generate validation code first because inline struct validation is removed. - ctx := codegen.NewAttributeContext(!expr.IsPrimitive(body.Type), false, !svr, "", sd.Scope) - validateRef = codegen.ValidationCode(body, nil, ctx, true, expr.IsAlias(body.Type), false, "body") + if svr { + // Generate validation code first because inline struct validation is removed. + ctx := jsonBodyContext(catalog, catalog.scope, true, true) + ctx.Pointer = !expr.IsPrimitive(body.Type) + validateRef = codegen.ValidationCode(body, nil, ctx, true, expr.IsAlias(body.Type), false, "body") + } if svr && expr.IsObject(body.Type) { // Body is an explicit object described in the design and in // this case the GoTypeRef is an inline struct definition. We @@ -2161,39 +3235,47 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * // generating the server body type pre-validation. body.Validation = nil } - varname = sd.Scope.GoTypeRef(body) + varname = httpctx.Scope.Ref(body, "") desc = body.Description } var init *InitData if !svr && att.Type != expr.Empty && needInit(body.Type) { var ( - name string - desc string - code string - origin string - err error - helpers []*codegen.TransformFunctionData + name string + desc string + code string + origin string + err error + helpers []*codegen.TransformFunctionData + declaration *codegen.NameDeclaration sourceVar = "p" svc = sd.Service ) { - name = fmt.Sprintf("New%s", codegen.Goify(sd.Scope.GoTypeName(body), true)) + if record != nil { + declaration = record.constructor + } else { + declaration = sd.clientBodyConstructors[clientBodyConstructorKey{endpoint: e, role: role}] + } + if declaration == nil { + panic(fmt.Sprintf("client body constructor for %s.%s was not submitted", svc.Name, e.Name())) + } + name = declaration.Name() desc = fmt.Sprintf("%s builds the HTTP request body from the payload of the %q endpoint of the %q service.", name, e.Name(), svc.Name) src := sourceVar - srcAtt := att // If design uses Body("name") syntax then need to use payload attribute // to transform. if o, ok := body.Meta["origin:attribute"]; ok { - srcObj := expr.AsObject(att.Type) origin = o[0] - srcAtt = srcObj.Attribute(origin) src += "." + codegen.Goify(origin, true) } - code, helpers, err = marshal(srcAtt, body, src, "body", svcctx, httpctx) + transformctx := jsonBodyContext(catalog, catalog.scope, true, svr) + transforms := sd.transforms.requests[clientBodyConstructorKey{endpoint: e, role: role}] + code, helpers, err = catalog.renderTransform(transforms.clientEncode, body, src, "body", svcctx, transformctx) if err != nil { - panic(err) // bug + sds.recordLinkError(err) } sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } @@ -2202,32 +3284,40 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * AttributeData: &AttributeData{ Name: "payload", VarName: sourceVar, - TypeRef: svc.Scope.GoFullTypeRef(att, pkg), + TypeRef: svcctx.Scope.Ref(att, svcctx.Pkg(att)), Type: att.Type, Validate: validateDef, - Example: att.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(att, sourceOwner), }, } init = &InitData{ + Declaration: declaration, Name: name, Description: desc, - ReturnTypeRef: sd.Scope.GoTypeRef(body), + ReturnTypeRef: ref, ReturnTypeAttribute: codegen.Goify(origin, true), ClientCode: code, ClientArgs: []*InitArgData{&arg}, } } - return &TypeData{ - Name: name, - VarName: varname, - Description: desc, - Def: def, - Ref: ref, - Init: init, - ValidateDef: validateDef, - ValidateRef: validateRef, - Example: body.Example(sds.Root.API.ExampleGenerator), + data := &TypeData{ + Name: name, + VarName: varname, + Description: desc, + Def: def, + Ref: ref, + Init: init, + ValidateDef: validateDef, + NestedValidateDef: nestedValidateDef, + ValidateRef: validateRef, + ValidationTarget: validationTarget, + Example: sds.Example(body, bodyOwner), + attribute: body, + } + if record == nil || data.Def == "" && data.ValidateDef == "" { + return data } + return catalog.bind(record, data) } // buildResponseBodyType builds the TypeData for a response body. The data @@ -2241,68 +3331,86 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * // svr is true if the function is generated for server side code // // view is the view name to add as a suffix to the type name. -func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, loc *codegen.Location, e *expr.HTTPEndpointExpr, svr bool, view *string, sd *ServiceData) *TypeData { +func (sds *ServicesData) buildResponseBodyType( + body, att *expr.AttributeExpr, + e *expr.HTTPEndpointExpr, + svr bool, + view *string, + sd *ServiceData, + transforms *plannedResponseTransforms, + sourceOwner, bodyOwner expr.ExampleIdentity, +) *TypeData { if body.Type == expr.Empty { return nil } + body, viewName := prepareResponseWireBody(body, view) var ( - name string - varname string - desc string - def string - ref string - validateDef string - validateRef string - viewName string - mustInit bool - - svc = sd.Service - httpctx = httpContext(sd.Scope, false, svr) - pkg = loc.PackageNameOrDefault(sd.Service.PkgName) - svcctx = serviceContext(pkg, sd.Service.Scope) + name string + varname string + desc string + def string + ref string + validateDef string + nestedValidateDef string + validateRef string + validationTarget string + mustInit bool + + svc = sd.Service + side = "client" ) - // Project the response body when the design fixes the response to a single - // view so the generated transport code uses the effective wire shape. - if view != nil && *view != "" { - viewName = *view - body = expr.DupAtt(body) - if rt, ok := body.Type.(*expr.ResultTypeExpr); ok { - var err error - rt, err = expr.Project(rt, *view) - if err != nil { - panic(err) - } - body.Type = rt + if svr { + side = "server" + } + svcctx := sds.serviceTypeContext(sd, side).Enter(att) + catalog := sd.wireTypes(svr) + policy := jsonBodyPolicy(false, svr, !svr && view == nil, viewName) + // Add each nested named field before body receives its chosen Go names. This + // keeps each copied request or response field tied to its own definition. + topLevel, _ := body.Type.(expr.UserType) + collectUserTypes(body.Type, func(ut expr.UserType) { + if topLevel != nil && ut == topLevel { + return + } + if d := sds.attributeTypeData(ut, false, !svr, svr, sd); d != nil { if svr { - sd.ServerTypeNames[rt.Name()] = struct{}{} + sd.ServerBodyAttributeTypes = append(sd.ServerBodyAttributeTypes, d) } else { - sd.ClientTypeNames[rt.Name()] = struct{}{} + sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) } } - } - + }) + record := catalog.lookupUser(body, wireResponseBody, policy) + catalog.applyNames(body, wireResponseBody, policy) + httpctx := jsonBodyContext(catalog, catalog.scope, false, svr) name = body.Type.Name() - ref = sd.Scope.GoTypeRef(body) + if record != nil { + name = record.name + ref = record.ref + } else { + ref = httpctx.Scope.Ref(body, "") + } mustInit = att.Type != expr.Empty && needInit(body.Type) - addMarshalTags(body, make(map[string]struct{})) - if ut, ok := body.Type.(expr.UserType); ok { // response body is a user type. - varname = codegen.Goify(ut.Name(), true) - def = goTypeDef(sd.Scope, ut.Attribute(), !svr, svr) + varname = record.name + def = goTypeDefForContext(ut.Attribute(), httpctx) desc = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body.", varname, svc.Name, e.Name()) if !svr && view == nil { // generate validation code for unmarshaled type (client-side). validateDef = codegen.ValidationCode(body, ut, httpctx, true, expr.IsAlias(body.Type), false, "body") + if record.needsNestedCall { + nestedValidateDef = codegen.ValidationCodeWithPathParameter(body, ut, httpctx, true, expr.IsAlias(body.Type), false, "body", "path") + } if validateDef != "" { target := "&body" if expr.IsArray(ut) { // result type collection target = "body" } - validateRef = fmt.Sprintf("err = Validate%s(%s)", varname, target) + validationTarget = target } } } else if !expr.IsPrimitive(body.Type) && mustInit { @@ -2318,38 +3426,30 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo // may be deduplicated away in client/types.go. if svr { name = codegen.Goify(e.Name(), true) + "ResponseBody" - varname = name + record = catalog.lookup(body, wireResponseBody, policy, name) + varname = record.name + name = record.name desc = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body.", varname, svc.Name, e.Name()) - def = goTypeDef(sd.Scope, body, !svr, svr) + def = goTypeDefForContext(body, httpctx) } else { - varname = sd.Scope.GoTypeRef(body) + varname = httpctx.Scope.Ref(body, "") desc = body.Description def = "" } - validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") + if !svr { + validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") + } } else { // response body is a primitive type. They are used as non-pointers when // encoding/decoding responses. - httpctx = httpContext(sd.Scope, false, true) - validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") - varname = sd.Scope.GoTypeRef(body) + httpctx = jsonBodyContext(catalog, catalog.scope, false, true) + if !svr { + validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") + } + varname = httpctx.Scope.Ref(body, "") desc = body.Description } - if svr { - sd.ServerTypeNames[name] = struct{}{} - // We collect the server body types need to generate a response body type - // here because the response body type would be different from the actual - // type in the HTTPResponseExpr since we projected the body type above. - // For client side, response body types are collected in "analyze" using - // the effective client response body. - collectUserTypes(body.Type, func(ut expr.UserType) { - if d := sds.attributeTypeData(ut, false, false, true, sd); d != nil { - sd.ServerBodyAttributeTypes = append(sd.ServerBodyAttributeTypes, d) - } - }) - } - var init *InitData if svr && mustInit { var ( @@ -2370,28 +3470,30 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo rtname = codegen.Goify(e.Name(), true) + "ResponseBody" rtref = rtname } else { - rtname = codegen.Goify(sd.Scope.GoTypeName(body), true) - rtref = sd.Scope.GoTypeRef(body) + rtname = record.name + rtref = ref } name = fmt.Sprintf("New%s", rtname) desc = fmt.Sprintf("%s builds the HTTP response body from the result of the %q endpoint of the %q service.", name, e.Name(), svc.Name) if view != nil { - svcctx = viewContext(sd.Service.ViewsPkg, sd.Service.ViewScope) + svcctx = sds.viewTypeContext(sd, "server").Enter(att) } src := sourceVar - srcAtt := att // If design uses Body("name") syntax then need to use result attribute // to transform. if o, ok := body.Meta["origin:attribute"]; ok { - srcObj := expr.AsObject(att.Type) origin = o[0] - srcAtt = srcObj.Attribute(origin) src += "." + codegen.Goify(origin, true) } - code, helpers, err = marshal(srcAtt, body, src, "body", svcctx, httpctx) + transformctx := jsonBodyContext(catalog, catalog.scope, false, svr) + transformctx.Scope = catalog.resolver(catalog.scope, policy) + if policy.view != "" { + transformctx.Scope = catalog.rootResolver(catalog.scope, policy, record) + } + code, helpers, err = catalog.renderTransform(transforms.serverEncode, body, src, "body", svcctx, transformctx) if err != nil { - panic(err) // bug + sds.recordLinkError(err) } sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } @@ -2399,10 +3501,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo if view != nil { ref += ".Projected" } - tref := svc.Scope.GoFullTypeRef(att, pkg) - if view != nil { - tref = svc.ViewScope.GoFullTypeRef(att, svc.ViewsPkg) - } + tref := svcctx.Scope.Ref(att, svcctx.Pkg(att)) arg := InitArgData{ Ref: ref, AttributeData: &AttributeData{ @@ -2411,7 +3510,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo TypeRef: tref, Type: att.Type, Validate: validateDef, - Example: att.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(att, sourceOwner), }, } init = &InitData{ @@ -2424,23 +3523,29 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo } } td := &TypeData{ - Name: name, - VarName: varname, - Description: desc, - Def: def, - Ref: ref, - Init: init, - ValidateDef: validateDef, - ValidateRef: validateRef, - Example: body.Example(sds.Root.API.ExampleGenerator), - View: viewName, + Name: name, + VarName: varname, + Description: desc, + Def: def, + Ref: ref, + Init: init, + ValidateDef: validateDef, + NestedValidateDef: nestedValidateDef, + ValidateRef: validateRef, + ValidationTarget: validationTarget, + Example: sds.Example(body, bodyOwner), + View: viewName, + } + if record == nil || td.Def == "" && td.ValidateDef == "" { + return td } - return td + return catalog.bind(record, td) } -func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, scope *codegen.NameScope) []*ParamData { +func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, owner expr.ExampleIdentity) []*ParamData { var params []*ParamData - sds.extractElements(pathElement, a, service, serviceContext("", scope), scope, func(el *Element, _ *expr.AttributeExpr) { + svcctx := sds.serviceTypeContext(sd, "server").Enter(service) + sds.extractElements(pathElement, a, service, svcctx, sd.Scope, owner, func(el *Element, _ *expr.AttributeExpr) { params = append(params, &ParamData{ Map: false, MapStringSlice: false, @@ -2450,9 +3555,10 @@ func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service return params } -func (sds *ServicesData) extractQueryParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, scope *codegen.NameScope) []*ParamData { +func (sds *ServicesData) extractQueryParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, owner expr.ExampleIdentity) []*ParamData { var params []*ParamData - sds.extractElements(queryElement, a, service, serviceContext("", scope), scope, func(el *Element, att *expr.AttributeExpr) { + svcctx := sds.serviceTypeContext(sd, "server").Enter(service) + sds.extractElements(queryElement, a, service, svcctx, sd.Scope, owner, func(el *Element, att *expr.AttributeExpr) { mp := expr.AsMap(att.Type) params = append(params, &ParamData{ Map: mp != nil, @@ -2466,9 +3572,9 @@ func (sds *ServicesData) extractQueryParams(a *expr.MappedAttributeExpr, service return params } -func (sds *ServicesData) extractHeaders(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope) []*HeaderData { +func (sds *ServicesData) extractHeaders(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope, owner expr.ExampleIdentity) []*HeaderData { var headers []*HeaderData - sds.extractElements(headerElement, a, svcAtt, svcCtx, scope, func(el *Element, _ *expr.AttributeExpr) { + sds.extractElements(headerElement, a, svcAtt, svcCtx, scope, owner, func(el *Element, _ *expr.AttributeExpr) { headers = append(headers, &HeaderData{ CanonicalName: http.CanonicalHeaderKey(el.HTTPName), Element: el, @@ -2477,9 +3583,9 @@ func (sds *ServicesData) extractHeaders(a *expr.MappedAttributeExpr, svcAtt *exp return headers } -func (sds *ServicesData) extractCookies(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope) []*CookieData { +func (sds *ServicesData) extractCookies(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope, owner expr.ExampleIdentity) []*CookieData { var cookies []*CookieData - sds.extractElements(cookieElement, a, svcAtt, svcCtx, scope, func(el *Element, _ *expr.AttributeExpr) { + sds.extractElements(cookieElement, a, svcAtt, svcCtx, scope, owner, func(el *Element, _ *expr.AttributeExpr) { c := &CookieData{Element: el} for n, v := range a.Meta { switch n { @@ -2536,7 +3642,7 @@ func (sds *ServicesData) extractCookies(a *expr.MappedAttributeExpr, svcAtt *exp // the service expression to compute field pointer semantics, // // - cookies do not track slice information (cookie values are scalars). -func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope, add func(el *Element, att *expr.AttributeExpr)) { +func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope, owner expr.ExampleIdentity, add func(el *Element, att *expr.AttributeExpr)) { codegen.WalkMappedAttr(a, func(name, elem string, required bool, c *expr.AttributeExpr) error { // nolint: errcheck if kind == pathElement { required = true @@ -2562,20 +3668,26 @@ func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAtt } att := makeHTTPType(attr) var ( - varn = scope.Name(codegen.Goify(name, false)) - typeRef = scope.GoTypeRef(att) - ft = svcAtt.Type + varn = scope.Name(codegen.Goify(name, false)) + typeRef = scope.GoTypeRef(att) + elemTypeRef string + ft = svcAtt.Type slice bool pointer bool fptr bool ) + if arr := expr.AsArray(att.Type); arr != nil { + elemCtx := svcCtx.Enter(arr.ElemType) + elemTypeRef = elemCtx.Scope.Ref(arr.ElemType, elemCtx.Pkg(arr.ElemType)) + } if kind != cookieElement { slice = expr.AsArray(att.Type) != nil } if kind != pathElement { pointer = a.IsPrimitivePointer(name, true) } + valueTypeRef := typeRef if pointer { typeRef = "*" + typeRef } @@ -2590,6 +3702,7 @@ func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAtt fptr = svcCtx.IsPrimitivePointer(name, svcAtt) } } + validationAttribute := att validate := codegen.AttributeValidationCode(att, nil, svcCtx, required, expr.IsAlias(att.Type), varn, name) isText := (kind == pathElement || kind == queryElement) && isStringMetaType(att) if isText { @@ -2601,28 +3714,36 @@ func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAtt v.Format = "" attNoFmt.Validation = &v } - validate = codegen.AttributeValidationCode(&attNoFmt, nil, svcCtx, required, expr.IsAlias(att.Type), varn+"Raw", name) + validationAttribute = &attNoFmt + validate = codegen.AttributeValidationCode(validationAttribute, nil, svcCtx, required, expr.IsAlias(att.Type), varn+"Raw", name) } add(&Element{ HTTPName: elem, Slice: slice, StringSlice: stringSlice, AttributeData: &AttributeData{ - Name: name, - Description: att.Description, - FieldName: fieldName, - FieldPointer: fptr, - FieldType: ft, - VarName: varn, - Required: required, - Type: att.Type, - TypeName: scope.GoTypeName(att), - TypeRef: typeRef, - Pointer: pointer, - Validate: validate, + Name: name, + Description: att.Description, + FieldName: fieldName, + FieldPointer: fptr, + FieldType: ft, + VarName: varn, + Required: required, + Type: att.Type, + TypeName: scope.GoTypeName(att), + TypeRef: typeRef, + ElemTypeRef: elemTypeRef, + Pointer: pointer, + Validate: validate, + CLIPlan: cli.NewFlagPlan( + validationAttribute, + scope.GoTypeName(validationAttribute), + valueTypeRef, + cliValidationRenderer(validate != "", validationAttribute, svcCtx, name), + ), IsTextUnmarshaler: isText, DefaultValue: att.DefaultValue, - Example: att.Example(sds.Root.API.ExampleGenerator.Field(svcAtt, name)), + Example: sds.FieldExample(att, svcAtt, name, owner), }, }, att) return nil @@ -2638,6 +3759,19 @@ func elementInitArg(el *Element) *InitArgData { return &InitArgData{Ref: att.VarName, AttributeData: &att} } +// cliValidationRenderer returns nil when the transport plan has no checks. A +// non-nil function writes checks for the concrete value parsed from a flag. +func cliValidationRenderer(enabled bool, attribute *expr.AttributeExpr, context *codegen.AttributeContext, name string) func(string) string { + if !enabled { + return nil + } + valueContext := context.Dup() + valueContext.Pointer = false + return func(target string) string { + return codegen.AttributeValidationCode(attribute, nil, valueContext, true, expr.IsAlias(attribute.Type), target, name) + } +} + // resultInitArg returns a result constructor argument backed by a copy of the // element attribute data. Result constructor arguments carry no description, // type name or default value: the constructor templates do not read them. @@ -2663,80 +3797,45 @@ func errorInitArg(el *Element) *InitArgData { // collectUserTypes traverses the given data type recursively and calls back the // given function for each attribute using a user type. -func collectUserTypes(dt expr.DataType, cb func(expr.UserType), seen ...map[string]struct{}) { +func collectUserTypes(dt expr.DataType, cb func(expr.UserType)) { + collectUserTypesRecursive(dt, cb, make(map[expr.UserType]struct{})) +} + +// collectUserTypesRecursive follows nested declarations once per authored +// origin so recursive copies terminate without hiding unrelated declarations. +func collectUserTypesRecursive(dt expr.DataType, cb func(expr.UserType), seen map[expr.UserType]struct{}) { if dt == expr.Empty { return } - var s map[string]struct{} - if len(seen) > 0 { - s = seen[0] - } else { - s = make(map[string]struct{}) - } switch actual := dt.(type) { case *expr.Object: for _, nat := range *actual { - collectUserTypes(nat.Attribute.Type, cb, seen...) + collectUserTypesRecursive(nat.Attribute.Type, cb, seen) } case *expr.Union: for _, nat := range actual.Values { - collectUserTypes(nat.Attribute.Type, cb, seen...) + collectUserTypesRecursive(nat.Attribute.Type, cb, seen) } case *expr.Array: - collectUserTypes(actual.ElemType.Type, cb, seen...) + collectUserTypesRecursive(actual.ElemType.Type, cb, seen) case *expr.Map: - collectUserTypes(actual.KeyType.Type, cb, seen...) - collectUserTypes(actual.ElemType.Type, cb, seen...) + collectUserTypesRecursive(actual.KeyType.Type, cb, seen) + collectUserTypesRecursive(actual.ElemType.Type, cb, seen) case expr.UserType: - if _, ok := s[actual.ID()]; ok { + origin := actual.Origin() + if _, ok := seen[origin]; ok { return } - s[actual.ID()] = struct{}{} + seen[origin] = struct{}{} cb(actual) - collectUserTypes(actual.Attribute().Type, cb, s) + collectUserTypesRecursive(actual.Attribute().Type, cb, seen) } } -func collectHTTPUnionTypes(att *expr.AttributeExpr, scope *codegen.NameScope, unions map[string]*service.UnionTypeData, seen map[string]struct{}) { - if att == nil || att.Type == expr.Empty { - return - } - switch dt := att.Type.(type) { - case expr.UserType: - if _, ok := seen[dt.ID()]; ok { - return - } - seen[dt.ID()] = struct{}{} - collectHTTPUnionTypes(dt.Attribute(), scope, unions, seen) - case *expr.Object: - for _, nat := range sortedNamedAttributes(*dt) { - collectHTTPUnionTypes(nat.Attribute, scope, unions, seen) - } - case *expr.Array: - collectHTTPUnionTypes(dt.ElemType, scope, unions, seen) - case *expr.Map: - collectHTTPUnionTypes(dt.KeyType, scope, unions, seen) - collectHTTPUnionTypes(dt.ElemType, scope, unions, seen) - case *expr.Union: - hash := dt.Hash() - if _, ok := unions[hash]; !ok { - unions[hash] = buildHTTPUnionTypeData(dt, scope) - } - for _, nat := range dt.Values { - collectHTTPUnionTypes(nat.Attribute, scope, unions, seen) - } - } -} - -// effectiveClientResponseBody returns the response body shape used by client -// code generation. When the design fixes the response to a single view, the -// returned attribute uses that projected ResultType so type collection, union -// collection, and client decode/init all agree on one transport body. -func effectiveClientResponseBody(body *expr.AttributeExpr, e *expr.HTTPEndpointExpr, md *service.MethodData) *expr.AttributeExpr { - view := clientResponseViewName(e, md) - if view == "" { - return body - } +// effectiveClientResponseBodyForView returns a copied response body containing +// the fields visible in one selected view. Type naming and client decoding both +// use this copy so they cannot disagree about its fields. +func effectiveClientResponseBodyForView(body *expr.AttributeExpr, view string) *expr.AttributeExpr { body = expr.DupAtt(body) rt, ok := body.Type.(*expr.ResultTypeExpr) if !ok { @@ -2750,6 +3849,24 @@ func effectiveClientResponseBody(body *expr.AttributeExpr, e *expr.HTTPEndpointE return body } +// clientSSEDataPointer reports whether a configured SSE data field uses the +// pointer layout required by client response validation. Complete primitive +// response bodies remain values. +func clientSSEDataPointer(endpoint *expr.HTTPEndpointExpr, body *expr.AttributeExpr) bool { + if endpoint.SSE == nil || endpoint.SSE.DataField == "" { + return false + } + object := expr.AsObject(body.Type) + if object == nil { + return false + } + attribute := object.Attribute(endpoint.SSE.DataField) + if attribute == nil { + panic(fmt.Sprintf("SSE data field %q is missing from the client response body", endpoint.SSE.DataField)) + } + return expr.IsPrimitive(attribute.Type) +} + // clientResponseViewName returns the response view used by client code // generation when the design fixes the response to a single view. An empty // string means the client must keep the unprojected transport body because the @@ -2767,73 +3884,74 @@ func clientResponseViewName(e *expr.HTTPEndpointExpr, md *service.MethodData) st return "" } -func buildHTTPUnionTypeData(u *expr.Union, scope *codegen.NameScope) *service.UnionTypeData { - att := &expr.AttributeExpr{Type: u} - name := scope.GoTypeName(att) - kindName := scope.Unique(name + "Kind") +// clientResponseViewNameExpr returns the one view selected by the HTTP design. +// An empty result means each streamed response may name any allowed view. +func clientResponseViewNameExpr(e *expr.HTTPEndpointExpr, result *expr.ResultTypeExpr) string { + if view, ok := e.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { + return view + } + if len(result.Views) == 1 { + return result.Views[0].Name + } + return "" +} +func buildHTTPUnionTypeData(u *expr.Union, scope codegen.Attributor, record *wireUnionRecord) *service.UnionTypeData { fields := make([]*service.UnionFieldData, len(u.Values)) for i, nat := range u.Values { fieldName := codegen.Goify(nat.Name, true) - fieldType := scope.GoTypeRef(nat.Attribute) - kindConst := kindName + fieldName + fieldType := scope.Ref(nat.Attribute, scope.Package(nat.Attribute)) fields[i] = &service.UnionFieldData{ - Name: nat.Name, - KindConst: kindConst, - FieldName: fieldName, - FieldType: fieldType, - Nilable: codegen.IsNilable(nat.Attribute.Type), - TypeTag: nat.Name, + Name: nat.Name, + KindConst: record.kindConsts[i], + Constructor: record.constructors[i], + KindDeclaration: record.kindDecls[i], + ConstructorDeclaration: record.ctorDecls[i], + FieldName: fieldName, + FieldType: fieldType, + Nilable: codegen.IsNilable(nat.Attribute.Type), + TypeTag: nat.Name, } } return &service.UnionTypeData{ - Name: name, - KindName: kindName, - Fields: fields, - TypeKey: u.GetTypeKey(), - ValueKey: u.GetValueKey(), + Name: record.name, + KindName: record.kindName, + TypeDeclaration: record.declaration, + KindDeclaration: record.kind, + Fields: fields, + TypeKey: u.GetTypeKey(), + ValueKey: u.GetValueKey(), } } -// sortedNamedAttributes returns object fields sorted by attribute name. -// Union naming uses NameScope uniqueness, so callers that discover unions while -// traversing objects must use a deterministic field order to avoid oscillating -// generated identifiers across runs. -func sortedNamedAttributes(attrs []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { - if len(attrs) < 2 { - return attrs - } - sorted := slices.Clone(attrs) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Name < sorted[j].Name - }) - return sorted +func (sds *ServicesData) attributeTypeData(ut expr.UserType, req, ptr, server bool, rd *ServiceData) *TypeData { + return sds.attributeTypeDataView(ut, req, ptr, server, "", rd) } -func (sds *ServicesData) attributeTypeData(ut expr.UserType, req, ptr, server bool, rd *ServiceData) *TypeData { +// attributeTypeDataView builds a nested declaration using the view policy +// that selected its enclosing response shape. +func (sds *ServicesData) attributeTypeDataView(ut expr.UserType, req, ptr, server bool, view string, rd *ServiceData) *TypeData { if ut == expr.Empty { return nil } - seen := rd.ServerTypeNames - if !server { - seen = rd.ClientTypeNames - } - if _, ok := seen[ut.Name()]; ok { - return nil - } - seen[ut.Name()] = struct{}{} var ( - name string - desc string - validate string - validateRef string - - att = &expr.AttributeExpr{Type: ut} - hctx = httpContext(rd.Scope, req, server) + name string + desc string + validate string + nestedValidate string + validateRef string + + att = expr.DupAtt(&expr.AttributeExpr{Type: ut}) + catalog = rd.wireTypes(server) + policy = wireTypePolicy{request: req, pointer: ptr, useDefault: hctxUseDefault(req, server), validate: req || !server, arrayElementPointer: req == server, view: view} ) - name = rd.Scope.GoTypeName(att) + ut = att.Type.(expr.UserType) + record := catalog.lookupUser(att, wireAttribute, policy) + catalog.applyNames(att, wireAttribute, policy) + hctx := jsonBodyContext(catalog, catalog.scope, req, server) + name = record.name ctx := "request" if !req { ctx = "response" @@ -2844,22 +3962,65 @@ func (sds *ServicesData) attributeTypeData(ut expr.UserType, req, ptr, server bo // requests server-side and CLI. // Alias types are validated inline in the parent type validate = codegen.ValidationCode(ut.Attribute(), ut, hctx, true, expr.IsAlias(ut), false, "body") + if record.needsNestedCall { + nestedValidate = codegen.ValidationCodeWithPathParameter(ut.Attribute(), ut, hctx, true, expr.IsAlias(ut), false, "body", "path") + } } + validationTarget := "" if validate != "" { - validateRef = fmt.Sprintf("err = Validate%s(v)", name) + validationTarget = "v" } - return &TypeData{ - Name: ut.Name(), - VarName: name, - Description: desc, - Def: goTypeDef(rd.Scope, ut.Attribute(), ptr, hctx.UseDefault), - Ref: rd.Scope.GoTypeRef(att), - ValidateDef: validate, - ValidateRef: validateRef, - Example: att.Example(sds.Root.API.ExampleGenerator), + return catalog.bind(record, &TypeData{ + Name: ut.Name(), + VarName: name, + Description: desc, + Def: goTypeDefForContext(ut.Attribute(), hctx), + Ref: record.ref, + ValidateDef: validate, + NestedValidateDef: nestedValidate, + ValidateRef: validateRef, + ValidationTarget: validationTarget, + Example: sds.Example(att, expr.UserTypeExampleIdentity(ut)), + }) +} + +// recordLinkError keeps the first failed conversion so Plan.Link can return it +// before callers receive files built from incomplete template data. +func (sds *ServicesData) recordLinkError(err error) { + if sds.linkErr == nil { + sds.linkErr = err + } +} + +// wireTypes returns the request and response types for the server or client package. +func (sd *ServiceData) wireTypes(server bool) *wireTypeCatalog { + if server { + return sd.serverWireTypes + } + return sd.clientWireTypes +} + +// jsonBodyPolicy describes one generated JSON body. Bodies being decoded keep +// required primitive array elements as pointers until validation rejects null. +func jsonBodyPolicy(request, server, validate bool, view string) wireTypePolicy { + // A server decodes a request, and a client decodes a response. + decode := request == server + return wireTypePolicy{ + request: request, + pointer: decode, + useDefault: !decode, + validate: validate, + arrayElementPointer: decode, + view: view, } } +// hctxUseDefault reports whether missing HTTP values receive their design +// defaults for the selected request or response side. +func hctxUseDefault(request, server bool) bool { + return !request && server || request && !server +} + // httpContext returns a context for attributes of types used to marshal and // unmarshal HTTP requests and responses. // @@ -2878,44 +4039,53 @@ func httpContext(scope *codegen.NameScope, request, svr bool) *codegen.Attribute return ctx } -// serviceContext returns an attribute context for service types. -func serviceContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(false, false, true, pkg, scope) +// wireHTTPContext returns the pointer and default-value rules for one generated +// HTTP package. It maps each copied field to the Go type name chosen for +// that particular request or response. +func wireHTTPContext(catalog *wireTypeCatalog, scope *codegen.NameScope, request, server bool) *codegen.AttributeContext { + context := httpContext(scope, request, server) + context.Scope = catalog.resolver(scope, wireTypePolicy{ + request: request, + pointer: context.Pointer, + useDefault: context.UseDefault, + }) + return context } -// viewContext returns an attribute context for projected types. -func viewContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(true, false, true, pkg, scope) +// jsonBodyContext uses pointer elements only while decoding a JSON body. This +// lets generated validation reject null before conversion to service values. +func jsonBodyContext(catalog *wireTypeCatalog, scope *codegen.NameScope, request, server bool) *codegen.AttributeContext { + context := wireHTTPContext(catalog, scope, request, server) + decode := request == server + context.ArrayElementPointer = decode + context.Scope = catalog.resolver(scope, wireTypePolicy{ + request: request, + pointer: context.Pointer, + useDefault: context.UseDefault, + arrayElementPointer: context.ArrayElementPointer, + }) + return context } -// unmarshal initializes a data structure defined by target type from a data -// structure defined by source type. The attributes in the source data -// structure are pointers and the attributes in the target data structure that -// have default values are non-pointers. Fields in target type are initialized -// with their default values (if any). -// -// source, target are the attributes used in the transformation -// -// sourceVar, targetVar are the variable names for source and target used in -// the transformation code -// -// sourceCtx, targetCtx are the source and target attribute contexts -func unmarshal(source, target *expr.AttributeExpr, sourceVar string, sourceCtx, targetCtx *codegen.AttributeContext) (string, []*codegen.TransformFunctionData, error) { - return codegen.GoTransform(source, target, sourceVar, "v", sourceCtx, targetCtx, "unmarshal", true) +// serviceTypeContext returns the service type names as referenced from the +// generated client or server package named by side. +func (sds *ServicesData) serviceTypeContext(sd *ServiceData, side string) *codegen.AttributeContext { + outputPackage := path.Join(sds.GenPkg(), sds.dir(), sd.Service.PathName, side) + return &codegen.AttributeContext{ + UseDefault: true, + Scope: sds.ServiceAttributor(sd.Service.Name, outputPackage), + } } -// marshal initializes a data structure defined by target type from a data -// structure defined by source type. The fields in the source and target -// data structure use non-pointers for attributes with default values. -// -// source, target are the attributes used in the transformation -// -// sourceVar, targetVar are the variable names for source and target used in -// the transformation code -// -// sourceCtx, targetCtx are the source and target attribute contexts -func marshal(source, target *expr.AttributeExpr, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext) (string, []*codegen.TransformFunctionData, error) { - return codegen.GoTransform(source, target, sourceVar, targetVar, sourceCtx, targetCtx, "marshal", true) +// viewTypeContext returns the result-view type names as referenced from the +// generated client or server package named by side. +func (sds *ServicesData) viewTypeContext(sd *ServiceData, side string) *codegen.AttributeContext { + outputPackage := path.Join(sds.GenPkg(), sds.dir(), sd.Service.PathName, side) + return &codegen.AttributeContext{ + Pointer: true, + UseDefault: true, + Scope: sds.ViewAttributor(sd.Service.Name, outputPackage), + } } // needConversion returns true if the type needs to be converted from a string. @@ -2953,26 +4123,33 @@ func isStringMetaType(c *expr.AttributeExpr) bool { } // addMarshalTags adds JSON, XML and Form tags to all inline object attributes recursively. -func addMarshalTags(att *expr.AttributeExpr, seen map[string]struct{}) { +func addMarshalTags(att *expr.AttributeExpr) { + addMarshalTagsRecursive(att, make(map[expr.UserType]struct{})) +} + +// addMarshalTagsRecursive annotates every inline object reachable through one +// declaration origin and stops when recursive copies return to that origin. +func addMarshalTagsRecursive(att *expr.AttributeExpr, seen map[expr.UserType]struct{}) { if ut, ok := att.Type.(expr.UserType); ok { - if _, ok := seen[ut.Hash()]; ok { + origin := ut.Origin() + if _, ok := seen[origin]; ok { return // avoid infinite recursions } - seen[ut.Hash()] = struct{}{} + seen[origin] = struct{}{} if expr.IsObject(ut.Attribute().Type) { for _, att := range *(expr.AsObject(att.Type)) { - addMarshalTags(att.Attribute, seen) + addMarshalTagsRecursive(att.Attribute, seen) } } return } if expr.IsArray(att.Type) { - addMarshalTags(expr.AsArray(att.Type).ElemType, seen) + addMarshalTagsRecursive(expr.AsArray(att.Type).ElemType, seen) return } if expr.IsMap(att.Type) { - addMarshalTags(expr.AsMap(att.Type).KeyType, seen) - addMarshalTags(expr.AsMap(att.Type).ElemType, seen) + addMarshalTagsRecursive(expr.AsMap(att.Type).KeyType, seen) + addMarshalTagsRecursive(expr.AsMap(att.Type).ElemType, seen) return } if !expr.IsObject(att.Type) { @@ -3027,6 +4204,21 @@ func upgradeParams(e *EndpointData, fn string) map[string]any { } } +// serviceHasViewedResult reports whether the selected endpoint sections +// reference a result containing only a selected view's fields from the service +// views package. +func serviceHasViewedResult(service *ServiceData, selected func(*EndpointData) bool) bool { + for _, endpoint := range service.Endpoints { + if selected != nil && !selected(endpoint) { + continue + } + if endpoint.Method.ViewedResult != nil { + return true + } + } + return false +} + // NeedDialer returns true if at least one method in the defined services // uses WebSocket for sending payload or result. func NeedDialer(data []*ServiceData) bool { diff --git a/http/codegen/service_data_purity_test.go b/http/codegen/service_data_purity_test.go index cceb97d271..0ac13dfaf6 100644 --- a/http/codegen/service_data_purity_test.go +++ b/http/codegen/service_data_purity_test.go @@ -82,9 +82,9 @@ func TestAnalyzeLeavesDesignExpressionsUnchanged(t *testing.T) { } } - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) for _, svc := range root.API.HTTP.Services { - require.NotNil(t, services.Get(svc.Name())) + require.NotNil(t, plan.services.Get(svc.Name())) } for _, svc := range root.API.HTTP.Services { diff --git a/http/codegen/service_data_traversal_test.go b/http/codegen/service_data_traversal_test.go new file mode 100644 index 0000000000..ac0f935f94 --- /dev/null +++ b/http/codegen/service_data_traversal_test.go @@ -0,0 +1,97 @@ +// This file verifies that HTTP body shaping and traversal visit unrelated +// declarations even when their semantic identifiers or structures match. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +func TestMakeHTTPTypeDistinguishesEqualUIDOrigins(t *testing.T) { + first := locatedHTTPTraversalType("First", "shared", "first/types") + second := locatedHTTPTraversalType("Second", "shared", "second/types") + body := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + + wire := makeHTTPType(body) + object := expr.AsObject(wire.Type) + wireFirst := object.Attribute("first").Type.(expr.UserType) + wireSecond := object.Attribute("second").Type.(expr.UserType) + require.NotContains(t, wireFirst.Attribute().Meta, "struct:pkg:path") + require.NotContains(t, wireSecond.Attribute().Meta, "struct:pkg:path") +} + +func TestCollectUserTypesDistinguishesEqualUIDOriginsAndStopsRecursion(t *testing.T) { + first := &expr.UserTypeExpr{TypeName: "First", UID: "shared"} + firstObject := &expr.Object{} + first.AttributeExpr = &expr.AttributeExpr{Type: firstObject} + firstObject.Set("self", &expr.AttributeExpr{Type: first}) + second := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}, + TypeName: "Second", + UID: "shared", + } + outer := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }}, + TypeName: "Outer", + } + + var names []string + collectUserTypes(outer, func(userType expr.UserType) { + names = append(names, userType.Name()) + }) + require.Equal(t, []string{"Outer", "First", "Second"}, names) +} + +func TestAddMarshalTagsDistinguishesEqualStructuralOrigins(t *testing.T) { + first := marshalTagTraversalType() + second := marshalTagTraversalType() + outer := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }}, + TypeName: "Outer", + } + + addMarshalTags(&expr.AttributeExpr{Type: outer}) + firstValue := expr.AsObject(expr.AsObject(first).Attribute("nested").Type).Attribute("value") + secondValue := expr.AsObject(expr.AsObject(second).Attribute("nested").Type).Attribute("value") + require.Equal(t, []string{"value"}, firstValue.Meta["struct:tag:json"]) + require.Equal(t, []string{"value"}, secondValue.Meta["struct:tag:json"]) +} + +// locatedHTTPTraversalType builds an authored declaration whose package path +// must be removed when Goa derives its transport type. +func locatedHTTPTraversalType(name, uid, packagePath string) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{}, + Meta: expr.MetaExpr{"struct:pkg:path": {packagePath}}, + }, + TypeName: name, + UID: uid, + } +} + +// marshalTagTraversalType builds a declaration with an inline object whose +// fields must receive transport serialization tags. +func marshalTagTraversalType() *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "nested", Attribute: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}}, + }}, + TypeName: "Shared", + UID: "shared", + } +} diff --git a/http/codegen/service_data_union_nilability_test.go b/http/codegen/service_data_union_nilability_test.go index 3b49baf22f..cba322bb91 100644 --- a/http/codegen/service_data_union_nilability_test.go +++ b/http/codegen/service_data_union_nilability_test.go @@ -1,3 +1,5 @@ +// This file verifies HTTP union records preserve the nilability of every +// branch when rendering a value that holds one selected branch. package codegen import ( @@ -11,7 +13,25 @@ import ( func TestBuildHTTPUnionTypeDataMarksNilableBranches(t *testing.T) { union := unionWithBranchTypes() - data := buildHTTPUnionTypeData(union, codegen.NewNameScope()) + kindNames := []string{"ValueKindArray", "ValueKindBool", "ValueKindBytes", "ValueKindMap", "ValueKindObject", "ValueKindString"} + constructorNames := []string{"NewValueArray", "NewValueBool", "NewValueBytes", "NewValueMap", "NewValueObject", "NewValueString"} + kindDeclarations := make([]*codegen.NameDeclaration, len(kindNames)) + constructorDeclarations := make([]*codegen.NameDeclaration, len(constructorNames)) + for index := range kindNames { + kindDeclarations[index] = codegen.NewExactName(codegen.NameConstant, kindNames[index]) + constructorDeclarations[index] = codegen.NewExactName(codegen.NameFunction, constructorNames[index]) + } + record := &wireUnionRecord{ + declaration: codegen.NewExactName(codegen.NameType, "Value"), + kind: codegen.NewExactName(codegen.NameType, "ValueKind"), + kindDecls: kindDeclarations, + ctorDecls: constructorDeclarations, + name: "Value", + kindName: "ValueKind", + kindConsts: kindNames, + constructors: constructorNames, + } + data := buildHTTPUnionTypeData(union, codegen.NewAttributeScope(codegen.NewNameScope()), record) nilable := make(map[string]bool, len(data.Fields)) for _, field := range data.Fields { diff --git a/http/codegen/service_data_union_order_test.go b/http/codegen/service_data_union_order_test.go index b4fadfcc48..ba0f2ca84f 100644 --- a/http/codegen/service_data_union_order_test.go +++ b/http/codegen/service_data_union_order_test.go @@ -1,3 +1,5 @@ +// This file verifies deterministic HTTP wire union identity and confirms that +// detached wire expressions do not retain service package ownership. package codegen import ( @@ -5,8 +7,7 @@ import ( "github.com/stretchr/testify/require" - cg "goa.design/goa/v3/codegen" - svc "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" ) @@ -53,22 +54,158 @@ func TestCollectHTTPUnionTypesDeterministicAcrossObjectOrder(t *testing.T) { }, } - forwardNames := collectHTTPUnionTypeNames(forward) - reverseNames := collectHTTPUnionTypeNames(reverse) + forwardNames := collectHTTPUnionTypeNames(t, forward) + reverseNames := collectHTTPUnionTypeNames(t, reverse) require.Len(t, forwardNames, 2) require.Equal(t, forwardNames, reverseNames) } -func collectHTTPUnionTypeNames(att *expr.AttributeExpr) map[string]string { - scope := cg.NewNameScope() - seen := make(map[string]struct{}) - unionByHash := make(map[string]*svc.UnionTypeData) - collectHTTPUnionTypes(att, scope, unionByHash, seen) +func TestCollectHTTPUnionTypesReusesSameShapedDeclarationsAndReferences(t *testing.T) { + first := makeUnionForOrderTest("Value", "bool", "number") + second := makeUnionForOrderTest("Value", "bool", "number") + bodies := &expr.AttributeExpr{ + Type: &expr.Object{ + { + Name: "first", + Attribute: &expr.AttributeExpr{Type: first}, + }, + { + Name: "second", + Attribute: &expr.AttributeExpr{Type: second}, + }, + }, + } + + catalog, generation := testWireTypeCatalog(t) + catalog.collect(bodies, wireAttribute, wireTypePolicy{}) + linkTestWireTypeCatalog(t, generation, catalog) + catalog.applyNames(bodies, wireAttribute, wireTypePolicy{}) - names := make(map[string]string, len(unionByHash)) - for hash, data := range unionByHash { - names[hash] = data.Name + emitted := make([]string, 0, len(catalog.unions)) + for _, union := range catalog.unionTypes() { + emitted = append(emitted, union.Name) + } + references := []string{ + catalog.resolver(catalog.scope, wireTypePolicy{}).Name(&expr.AttributeExpr{Type: first}, "", false, false), + catalog.resolver(catalog.scope, wireTypePolicy{}).Name(&expr.AttributeExpr{Type: second}, "", false, false), + } + require.Equal(t, []string{"Value"}, emitted) + require.Equal(t, []string{"Value", "Value"}, references) +} + +func TestHTTPServiceDataReusesSameShapedMethodBodyUnions(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("values", func() { + dsl.Method("first", func() { + dsl.Payload(func() { + dsl.OneOf("Value", sameShapedValueUnionDSL) + }) + dsl.HTTP(func() { + dsl.POST("/first") + }) + }) + dsl.Method("second", func() { + dsl.Payload(func() { + dsl.OneOf("Value", sameShapedValueUnionDSL) + }) + dsl.HTTP(func() { + dsl.POST("/second") + }) + }) + }) + }) + + data := linkedHTTPPlanForRoot(t, root).services.Get("values") + require.NotNil(t, data) + for _, catalog := range []*wireTypeCatalog{data.serverWireTypes, data.clientWireTypes} { + unions := catalog.unionTypes() + emitted := make([]string, len(unions)) + for i, union := range unions { + emitted[i] = union.Name + } + require.Equal(t, []string{"Value"}, emitted) + } + require.Contains(t, data.Endpoint("first").Payload.Request.ServerBody.Def, "Value *Value ") + require.Contains(t, data.Endpoint("second").Payload.Request.ServerBody.Def, "Value *Value ") +} + +func TestMakeHTTPTypeRemovesServicePackageOwnershipFromWireCopy(t *testing.T) { + nested := &expr.UserTypeExpr{ + TypeName: "Nested", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "choice", Attribute: &expr.AttributeExpr{Type: makeUnionForOrderTest("Choice", "text", "number")}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + outer := &expr.UserTypeExpr{ + TypeName: "Envelope", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "nested", Attribute: &expr.AttributeExpr{Type: nested}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + + wire := makeHTTPType(&expr.AttributeExpr{Type: outer}) + wireOuter := wire.Type.(expr.UserType) + wireNested := expr.AsObject(wireOuter.Attribute().Type).Attribute("nested").Type.(expr.UserType) + + require.NotContains(t, wireOuter.Attribute().Meta, "struct:pkg:path") + require.NotContains(t, wireNested.Attribute().Meta, "struct:pkg:path") + require.Contains(t, outer.Attribute().Meta, "struct:pkg:path") + require.Contains(t, nested.Attribute().Meta, "struct:pkg:path") +} + +func TestStreamingHTTPTypeRemovesServicePackageOwnershipFromWireCopy(t *testing.T) { + nested := &expr.UserTypeExpr{ + TypeName: "Nested", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + outer := &expr.UserTypeExpr{ + TypeName: "Envelope", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "nested", Attribute: &expr.AttributeExpr{Type: nested}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + body := &expr.AttributeExpr{Type: outer} + endpoint := &expr.HTTPEndpointExpr{StreamingBody: body} + + wire := new(shapedBodies).streaming(endpoint) + wireOuter := wire.Type.(expr.UserType) + wireNested := expr.AsObject(wireOuter.Attribute().Type).Attribute("nested").Type.(expr.UserType) + + require.NotContains(t, wireOuter.Attribute().Meta, "struct:pkg:path") + require.NotContains(t, wireNested.Attribute().Meta, "struct:pkg:path") + require.Contains(t, outer.Attribute().Meta, "struct:pkg:path") + require.Contains(t, nested.Attribute().Meta, "struct:pkg:path") +} + +func sameShapedValueUnionDSL() { + dsl.Attribute("bool", dsl.Boolean) + dsl.Attribute("number", dsl.Float64) +} + +func collectHTTPUnionTypeNames(t *testing.T, att *expr.AttributeExpr) map[string]string { + t.Helper() + catalog, generation := testWireTypeCatalog(t) + catalog.collect(att, wireAttribute, wireTypePolicy{}) + linkTestWireTypeCatalog(t, generation, catalog) + + names := make(map[string]string, len(catalog.unions)) + for _, record := range catalog.unions { + names[record.identity.definition.Hash()] = record.data.Name } return names } @@ -83,8 +220,5 @@ func makeUnionForOrderTest(typeName string, variants ...string) *expr.Union { }, } } - return &expr.Union{ - TypeName: typeName, - Values: values, - } + return &expr.Union{TypeName: typeName, Values: values} } diff --git a/http/codegen/service_imports.go b/http/codegen/service_imports.go new file mode 100644 index 0000000000..021cef482c --- /dev/null +++ b/http/codegen/service_imports.go @@ -0,0 +1,179 @@ +// This file derives imports from the HTTP endpoints rendered into one +// generated file. Streaming-only files pass only their streaming endpoints. +package codegen + +import ( + "path" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// addPlannedFileImports adds the service-type packages recorded for file before +// generation names were frozen. +func addPlannedFileImports(file *codegen.File, services *ServicesData) *codegen.File { + if file == nil { + return nil + } + codegen.AddImport(file.SectionTemplates[0], services.fileImports[filepathKey(file.Path)]...) + return file +} + +// generatedFileOutputPackage returns the import path of the package that owns +// a file written below the generated directory. +func generatedFileOutputPackage(services *ServicesData, filePath string) string { + outputPath := strings.TrimPrefix(strings.ReplaceAll(filePath, "\\", "/"), codegen.Gendir+"/") + return path.Join(services.GenPkg(), path.Dir(outputPath)) +} + +// serviceDataForOutput copies the package-name fields that a template writes +// so they match the imports selected by its actual output package. +func serviceDataForOutput(data *ServiceData, services *ServicesData, outputPackage string) *ServiceData { + serviceCopy := *data.Service + serviceCopy.PkgName = services.ServiceImport(outputPackage, data.Service.Name).Name + copy := *data + copy.Service = &serviceCopy + copy.Endpoints = make([]*EndpointData, len(data.Endpoints)) + for index, endpoint := range data.Endpoints { + endpointCopy := *endpoint + endpointCopy.ServicePkgName = serviceCopy.PkgName + copy.Endpoints[index] = &endpointCopy + } + return © +} + +// exampleServiceDataForOutput gives local variables the unique service package +// path chosen for this generation. Example files may use several services at +// once, and two service names can produce the same Go name. +func exampleServiceDataForOutput(data *ServiceData, services *ServicesData, outputPackage string) *ServiceData { + copy := serviceDataForOutput(data, services, outputPackage) + copy.Service.VarName = codegen.Goify(copy.Service.PathName, false) + return copy +} + +// serviceReferenceAttributes returns the named service attributes referenced +// by generated HTTP or JSON-RPC endpoint sections, including the nested result +// field selected as SSE event data. +func serviceReferenceAttributes(endpoints ...*expr.HTTPEndpointExpr) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, endpoint := range endpoints { + method := endpoint.MethodExpr + attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result, method.StreamingResult) + if endpoint.SSE != nil && endpoint.SSE.DataField != "" { + event := method.Result + if method.HasMixedResults() { + event = method.StreamingResult + } + if object := expr.AsObject(event.Type); object != nil { + attributes = append(attributes, object.Attribute(endpoint.SSE.DataField)) + } + } + for _, methodError := range method.Errors { + attributes = append(attributes, methodError.AttributeExpr) + } + } + return attributes +} + +// planHTTPAttributeImports records the metadata and relocated generated types +// referenced by transport conversion code in one output package. +func planHTTPAttributeImports(generation *codegen.Generation, outputPackage *codegen.GeneratedPackage, attributes ...*expr.AttributeExpr) ([]string, error) { + seen := make(map[expr.UserType]struct{}) + paths := make(map[string]struct{}) + var visit func(*expr.AttributeExpr) error + visit = func(attribute *expr.AttributeExpr) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + if _, spec := codegen.GetMetaType(attribute); spec != nil && spec.Path != outputPackage.ImportPath() { + if err := outputPackage.DeclareImport(spec); err != nil { + return err + } + paths[spec.Path] = struct{}{} + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if location := codegen.UserTypeLocation(actual); location != nil { + importPath := path.Join(generation.GenPkg(), location.RelImportPath) + if importPath != outputPackage.ImportPath() { + if err := outputPackage.ReserveGeneratedImport(codegen.NewImport( + strings.ToLower(codegen.Goify(path.Base(importPath), false)), + importPath, + )); err != nil { + return err + } + paths[importPath] = struct{}{} + } + } + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return nil + } + seen[origin] = struct{}{} + return visit(actual.Attribute()) + case *expr.Object: + for _, named := range *actual { + if err := visit(named.Attribute); err != nil { + return err + } + } + case *expr.Array: + return visit(actual.ElemType) + case *expr.Map: + if err := visit(actual.KeyType); err != nil { + return err + } + return visit(actual.ElemType) + case *expr.Union: + for _, named := range actual.Values { + if err := visit(named.Attribute); err != nil { + return err + } + } + } + return nil + } + for _, attribute := range attributes { + if err := visit(attribute); err != nil { + return nil, err + } + } + result := make([]string, 0, len(paths)) + for importPath := range paths { + result = append(result, importPath) + } + sort.Strings(result) + return result, nil +} + +// filepathKey normalizes generated paths so file writers on every platform +// read the same planned import record. +func filepathKey(filePath string) string { + return strings.ReplaceAll(filePath, "\\", "/") +} + +// httpWebSocketEndpoints returns only the endpoints whose stream sections are +// rendered into WebSocket files. +func httpWebSocketEndpoints(svc *expr.HTTPServiceExpr) []*expr.HTTPEndpointExpr { + var endpoints []*expr.HTTPEndpointExpr + for _, endpoint := range svc.HTTPEndpoints { + if endpoint.UsesWebSocket() { + endpoints = append(endpoints, endpoint) + } + } + return endpoints +} + +// httpSSEEndpoints returns only the endpoints whose stream sections are +// rendered into Server-Sent Events files. +func httpSSEEndpoints(svc *expr.HTTPServiceExpr) []*expr.HTTPEndpointExpr { + var endpoints []*expr.HTTPEndpointExpr + for _, endpoint := range svc.HTTPEndpoints { + if endpoint.UsesSSE() { + endpoints = append(endpoints, endpoint) + } + } + return endpoints +} diff --git a/http/codegen/sse.go b/http/codegen/sse.go index d1a7ffc7c9..13c67bebb9 100644 --- a/http/codegen/sse.go +++ b/http/codegen/sse.go @@ -1,3 +1,6 @@ +// This file builds the values used to write HTTP server-sent event code. +// Service event types keep the names chosen earlier, while the HTTP package +// defines the request and response body types. package codegen import ( @@ -11,12 +14,38 @@ import ( ) type ( + // SSEValueData describes one value written to or read from an SSE line. + // Kind selects its generated conversion. TypeRef keeps a declared Go type + // when the client rebuilds the service result. + SSEValueData struct { + // Kind is the designed kind of the value. + Kind expr.Kind + // TypeRef is the Go type assigned by the generated client. + TypeRef string + // Named reports whether TypeRef is a declared service type. + Named bool + // Pointer reports whether the service field stores a primitive pointer. + Pointer bool + // ClientPointer reports whether the validated HTTP body stores this + // primitive as a pointer before conversion to the service event. + ClientPointer bool + } + // SSEData contains the data needed to render struct type that // implements the server and client stream interface for SSE. SSEData struct { - // StructName is the name of the generated struct which encapsulates the - // server implementation. + // StructName is the server stream type name kept for existing plugins. + // + // Deprecated: Use StructDeclaration.Name() after planning so name collisions are handled. StructName string + // StructDeclaration is the generated Go type name used by the server stream. + StructDeclaration *codegen.NameDeclaration + // ClientInterfaceDeclaration is the generated Go type name used by the client stream interface. + ClientInterfaceDeclaration *codegen.NameDeclaration + // ClientStructDeclaration is the generated Go type name used by the client stream implementation. + ClientStructDeclaration *codegen.NameDeclaration + // ClientInitDeclaration is the generated Go function name used by the client stream constructor. + ClientInitDeclaration *codegen.NameDeclaration // Interface is the fully qualified name of the interface that // the struct implements. Interface string @@ -30,24 +59,37 @@ type ( SendWithContextDesc string // EventTypeRef is the fully qualified type ref for the event type. EventTypeRef string - // EventTypeName is the name of the event type without package qualifier. + // EventTypeName is the fully qualified non-pointer type used to allocate an event. EventTypeName string // EventIsStruct indicates whether the SSE method return type is a struct. EventIsStruct bool - // DataFieldTypeRef is the fully qualified type ref for the data field if any. + // DataFieldTypeRef is the final Go type of the mapped data field kept for + // existing plugins. It is empty when the whole event is data. + // + // Deprecated: Use Data.TypeRef. DataFieldTypeRef string // DataField is the name of the result type event data attribute if any. // If empty, the entire result type is used as the data field. DataField string + // Data describes the exact value carried by each data line. + Data SSEValueData // IDField is the name of the result type event ID attribute if any. // If empty, no id field is included in the event. IDField string + // ClientIDPointer reports whether the validated HTTP body stores IDField + // as a pointer before conversion to the service event. + ClientIDPointer bool // EventField is the name of the result type event field if any. // If empty, no event field is included in the event. EventField string + // ClientEventPointer reports whether the validated HTTP body stores + // EventField as a pointer before conversion to the service event. + ClientEventPointer bool // RetryField is the name of the result type event retry field if any. // If empty, no retry field is included in the event. RetryField string + // Retry describes the exact integer type carried by the retry line. + Retry *SSEValueData // RequestIDField is the name of the payload field that maps to the Last-Event-ID header if any. // If empty, no last event id is included in the request. RequestIDField string @@ -55,11 +97,23 @@ type ( RequestIDPointer bool // HasResponseBody indicates whether an HTTP response body converter exists for this endpoint. HasResponseBody bool + // Response is the successful HTTP response whose body types encode and + // decode stream events. + Response *ResponseData + // ClientEventCode converts the validated HTTP event body into the service + // event returned by Recv. It is present for methods with different ordinary + // and streaming result types. + ClientEventCode string + // VariableView reports whether SetView selects the result body used by all + // events sent for one HTTP request. + VariableView bool + // DefaultView is used when SetView receives an empty string. + DefaultView string } ) // initSSEData initializes the SSE related data in ed. -func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { +func (sds *ServicesData) initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { if !e.UsesSSE() { return } @@ -73,9 +127,13 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { if e.MethodExpr.HasMixedResults() && e.MethodExpr.StreamingResult != nil { // For mixed results, use StreamingResult for SSE events eventAttr = e.MethodExpr.StreamingResult + if eventAttr.Type == expr.Empty { + eventAttr = e.MethodExpr.Result + } + svcctx := sds.serviceTypeContext(sd, "server").Enter(eventAttr) eventType = &ResultData{ - Name: md.StreamingResult, - Ref: sd.Service.Scope.GoFullTypeRef(eventAttr, svc.PkgName), + Name: svcctx.Scope.Name(eventAttr, svcctx.Pkg(eventAttr), false, true), + Ref: svcctx.Scope.Ref(eventAttr, svcctx.Pkg(eventAttr)), IsStruct: expr.IsObject(eventAttr.Type), } } else { @@ -88,7 +146,16 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { sendWithContextDesc := fmt.Sprintf("%s streams instances of %q to the %q endpoint SSE connection with context.", md.ServerStream.SendWithContextName, eventType.Name, md.Name) // Convert attribute names to Go field names - var dataFieldVar, dataFieldTypeRef, idFieldVar, eventFieldVar, retryFieldVar string + var ( + dataFieldVar string + dataFieldTypeRef string + dataField *expr.AttributeExpr + idFieldVar string + eventFieldVar string + retryFieldVar string + retryField *expr.AttributeExpr + ) + svcctx := sds.serviceTypeContext(sd, "server").Enter(eventAttr) if obj := expr.AsObject(eventAttr.Type); obj != nil { for _, nat := range *obj { switch nat.Name { @@ -98,21 +165,27 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { eventFieldVar = codegen.GoifyAtt(nat.Attribute, nat.Name, true) case e.SSE.RetryField: retryFieldVar = codegen.GoifyAtt(nat.Attribute, nat.Name, true) + retryField = nat.Attribute case e.SSE.DataField: dataFieldVar = codegen.GoifyAtt(nat.Attribute, nat.Name, true) - dataFieldTypeRef = sd.Service.Scope.GoFullTypeRef(nat.Attribute, svc.PkgName) + dataField = nat.Attribute + fieldctx := svcctx.Enter(nat.Attribute) + dataFieldTypeRef = fieldctx.Scope.Ref(nat.Attribute, fieldctx.Pkg(nat.Attribute)) } } } - // Determine if the Last-Event-ID mapped payload attribute is a primitive pointer + // Record the exact service field that receives Last-Event-ID and whether it + // uses a pointer. + ridField := "" ridPtr := false if e.SSE.RequestIDField != "" { + attribute := e.MethodExpr.Payload.Find(e.SSE.RequestIDField) + ridField = codegen.GoifyAtt(attribute, e.SSE.RequestIDField, true) ridPtr = e.MethodExpr.Payload.IsPrimitivePointer(e.SSE.RequestIDField, true) } ed.SSE = &SSEData{ - StructName: md.ServerStream.VarName, Interface: fmt.Sprintf("%s.%s", svc.PkgName, md.ServerStream.Interface), SendName: md.ServerStream.SendName, SendDesc: sendDesc, @@ -126,17 +199,91 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { IDField: idFieldVar, EventField: eventFieldVar, RetryField: retryFieldVar, - RequestIDField: e.SSE.RequestIDField, + RequestIDField: ridField, RequestIDPointer: ridPtr, + VariableView: md.ViewedResult != nil && md.ViewedResult.ViewName == "", } - - // Mixed results SSE uses the streaming result type for events, not the unary - // HTTP response body type. Disable HTTP response body conversion in the SSE - // stream implementation and marshal the event value directly. + if retryField != nil { + fieldctx := svcctx.Enter(retryField) + ed.SSE.Retry = &SSEValueData{ + Kind: retryField.Type.Kind(), + TypeRef: fieldctx.Scope.Ref(retryField, fieldctx.Pkg(retryField)), + Pointer: eventAttr.IsPrimitivePointer(e.SSE.RetryField, true), + } + } + if ed.SSE.VariableView { + for _, view := range md.ViewedResult.Views { + if view.Name == expr.DefaultView { + ed.SSE.DefaultView = view.Name + break + } + } + if ed.SSE.DefaultView == "" { + panic(fmt.Sprintf("viewed SSE method %q has no default view", md.Name)) + } + } + // A mixed method has one ordinary result and a different streamed result. + // Build the streamed result's own JSON body instead of reusing the ordinary + // response body or encoding the service struct directly. if ed.HasMixedResults { - ed.SSE.HasResponseBody = false + body := sd.bodies.streamingResult(e) + owner := expr.MethodStreamingResultExampleIdentity(e.MethodExpr) + transforms := sd.transforms.streamingResults[e] + serverBody := sds.buildResponseBodyType(body, eventAttr, e, true, nil, sd, transforms, owner, owner) + clientBody := sds.buildResponseBodyType(body, eventAttr, e, false, nil, sd, nil, owner, owner) + clientObject := expr.AsObject(body.Type) + ed.SSE.ClientIDPointer = sseBodyFieldPointer(clientObject, e.SSE.IDField) + ed.SSE.ClientEventPointer = sseBodyFieldPointer(clientObject, e.SSE.EventField) + if ed.SSE.Retry != nil { + ed.SSE.Retry.ClientPointer = sseBodyFieldPointer(clientObject, e.SSE.RetryField) + } + clientCode := "" + switch { + case body.Type == expr.Empty: + case transforms.clientDecodeDirect: + clientCode = "result := body" + case transforms.clientDecode.record != nil: + transformContext := jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transformContext.Scope = sd.clientWireTypes.resolver(sd.clientWireTypes.scope, jsonBodyPolicy(false, false, true, "")) + serviceContext := sds.serviceTypeContext(sd, "client").Enter(eventAttr) + var helpers []*codegen.TransformFunctionData + var err error + clientCode, helpers, err = sd.clientWireTypes.renderTransform( + transforms.clientDecode, + body, + "body", + "result", + transformContext, + serviceContext, + ) + if err != nil { + sds.recordLinkError(err) + } else { + sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } + default: + sds.recordLinkError(fmt.Errorf("mixed SSE client result for %q has no planned conversion", e.Name())) + } + ed.SSE.Response = &ResponseData{ClientBody: clientBody} + if serverBody != nil { + ed.SSE.Response.ServerBody = []*TypeData{serverBody} + } + ed.SSE.ClientEventCode = clientCode + ed.SSE.HasResponseBody = serverBody != nil + if dataField == nil { + dataField = body + dataFieldTypeRef = eventType.Ref + if serverBody != nil { + dataFieldTypeRef = serverBody.Ref + } + } + ed.SSE.Data = sseValueData(eventAttr, dataField, dataFieldTypeRef, e.SSE.DataField) + ed.SSE.Data.ClientPointer = sseBodyFieldPointer(clientObject, e.SSE.DataField) return } + if len(ed.Result.Responses) > 0 { + ed.SSE.Response = ed.Result.Responses[0] + } for _, resp := range ed.Result.Responses { if len(resp.ServerBody) > 0 { @@ -144,34 +291,133 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { break } } + dataAttribute := dataField + dataTypeRef := dataFieldTypeRef + if dataAttribute == nil { + dataAttribute = eventAttr + dataTypeRef = eventType.Ref + if ed.SSE.HasResponseBody && len(e.Responses) > 0 { + dataAttribute = sd.bodies.response(e.Responses[0]) + } + } + ed.SSE.Data = sseValueData(eventAttr, dataAttribute, dataTypeRef, e.SSE.DataField) +} + +// sseValueData records the exact conversion selected for one SSE value. +func sseValueData(event, value *expr.AttributeExpr, typeRef, field string) SSEValueData { + pointer := field != "" && event.IsPrimitivePointer(field, true) + if field == "" && value != event { + if origin, ok := value.Meta["origin:attribute"]; ok && len(origin) > 0 { + pointer = event.IsPrimitivePointer(origin[0], true) + } + } + kind := sseValueKind(value.Type) + named := false + if expr.IsPrimitive(value.Type) { + named = typeRef != codegen.GoNativeTypeName(expr.Primitive(kind)) + } + return SSEValueData{Kind: kind, TypeRef: typeRef, Named: named, Pointer: pointer} +} + +// sseBodyFieldPointer reports whether client validation keeps one primitive +// event field as a pointer so it can distinguish a missing value from zero. +func sseBodyFieldPointer(object *expr.Object, field string) bool { + if object == nil || field == "" { + return false + } + attribute := object.Attribute(field) + return attribute != nil && expr.IsPrimitive(attribute.Type) +} + +// sseValueKind returns the primitive or structured kind beneath a declared +// type name. Generated assignments still use the declared Go type in TypeRef. +func sseValueKind(dataType expr.DataType) expr.Kind { + switch actual := dataType.(type) { + case *expr.UserTypeExpr: + return sseValueKind(actual.Type) + case *expr.ResultTypeExpr: + return sseValueKind(actual.Type) + default: + return actual.Kind() + } +} + +// sseTemplateFuncs returns the generation-time type tests used by SSE +// templates. Each test removes every other conversion from generated code. +func sseTemplateFuncs() map[string]any { + return map[string]any{ + "ssePrimitive": func(value SSEValueData) bool { + switch value.Kind { + case expr.BooleanKind, expr.IntKind, expr.Int32Kind, expr.Int64Kind, + expr.UIntKind, expr.UInt32Kind, expr.UInt64Kind, + expr.Float32Kind, expr.Float64Kind, expr.StringKind, expr.BytesKind: + return true + default: + return false + } + }, + "sseString": func(value SSEValueData) bool { + return value.Kind == expr.StringKind + }, + "sseBytes": func(value SSEValueData) bool { + return value.Kind == expr.BytesKind + }, + "sseBoolean": func(value SSEValueData) bool { + return value.Kind == expr.BooleanKind + }, + "sseSignedInteger": func(value SSEValueData) bool { + return value.Kind == expr.IntKind || value.Kind == expr.Int32Kind || value.Kind == expr.Int64Kind + }, + "sseUnsignedInteger": func(value SSEValueData) bool { + return value.Kind == expr.UIntKind || value.Kind == expr.UInt32Kind || value.Kind == expr.UInt64Kind + }, + "sseFloat": func(value SSEValueData) bool { + return value.Kind == expr.Float32Kind || value.Kind == expr.Float64Kind + }, + "sseBitSize": func(value SSEValueData) int { + switch value.Kind { + case expr.Int32Kind, expr.UInt32Kind, expr.Float32Kind: + return 32 + case expr.Int64Kind, expr.UInt64Kind, expr.Float64Kind: + return 64 + default: + return 0 + } + }, + } } // sseServerFile returns the file implementing the SSE server // streaming implementation if any. -func sseServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func sseServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasSSE(data) { return nil } - path := filepath.Join(codegen.Gendir, "http", codegen.SnakeCase(svc.Name()), "server", "sse.go") + path := filepath.Join(codegen.Gendir, "http", data.Service.PathName, "server", "sse.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) tmplSections := sseTemplateSections(data) sections := make([]*codegen.SectionTemplate, 0, 1+len(tmplSections)) + imports := []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "io"}, + {Path: "net/http"}, + {Path: "sync"}, + {Path: "time"}, + {Path: "encoding/json"}, + {Path: "fmt"}, + services.ServiceImport(outputPackage, svc.Name()), + } + if serviceHasVariableViewedResult(data, IsSSEEndpoint) { + imports = append(imports, codegen.GoaImport("")) + } sections = append(sections, codegen.Header( "sse", "server", - []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "sync"}, - {Path: "time"}, - {Path: "encoding/json"}, - {Path: "fmt"}, - {Path: genpkg + "/" + codegen.SnakeCase(svc.Name()), Name: data.Service.PkgName}, - {Path: genpkg + "/" + codegen.SnakeCase(svc.Name()) + "/views", Name: data.Service.ViewsPkg}, - }, + imports, ), ) sections = append(sections, tmplSections...) @@ -185,14 +431,14 @@ func sseTemplateSections(data *ServiceData) []*codegen.SectionTemplate { if ed.SSE == nil { continue } + funcs := sseTemplateFuncs() + funcs["dict"] = dict + funcs["goify"] = codegen.Goify sections = append(sections, &codegen.SectionTemplate{ - Name: "server-sse", - Source: httpTemplates.Read(serverSseT, sseFormatP), - Data: ed, - FuncMap: map[string]any{ - "dict": dict, - "goify": codegen.Goify, - }, + Name: "server-sse", + Source: httpTemplates.Read(serverSseT, sseFormatP), + Data: ed, + FuncMap: funcs, }) } return sections @@ -225,3 +471,17 @@ func IsSSEEndpoint(ed *EndpointData) bool { func HasSSE(data *ServiceData) bool { return slices.ContainsFunc(data.Endpoints, IsSSEEndpoint) } + +// serviceHasVariableViewedResult reports whether a selected endpoint carries +// one of multiple legal views at runtime. +func serviceHasVariableViewedResult(service *ServiceData, selected func(*EndpointData) bool) bool { + for _, endpoint := range service.Endpoints { + if selected != nil && !selected(endpoint) { + continue + } + if endpoint.Method.ViewedResult != nil && endpoint.Method.ViewedResult.ViewName == "" { + return true + } + } + return false +} diff --git a/http/codegen/sse_client.go b/http/codegen/sse_client.go index 1296bb46be..0dd8c628e2 100644 --- a/http/codegen/sse_client.go +++ b/http/codegen/sse_client.go @@ -2,7 +2,6 @@ package codegen import ( "path/filepath" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" @@ -10,33 +9,41 @@ import ( // sseClientFile returns the file implementing the SSE client code for SSE endpoints if any. // Relies on SSEData (ed.SSE) for all codegen needs. -func sseClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func sseClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasSSE(data) { return nil } - path := filepath.Join(codegen.Gendir, "http", codegen.SnakeCase(svc.Name()), "client", "sse.go") + path := filepath.Join(codegen.Gendir, "http", data.Service.PathName, "client", "sse.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) tmplSections := sseClientTemplateSections(data) sections := make([]*codegen.SectionTemplate, 0, 1+len(tmplSections)) + imports := []*codegen.ImportSpec{ + {Path: "bytes"}, + {Path: "context"}, + {Path: "encoding/json"}, + {Path: "errors"}, + {Path: "io"}, + {Path: "net/http"}, + {Path: "fmt"}, + {Path: "strings"}, + {Path: "strconv"}, + {Path: "sync"}, + services.ServiceImport(outputPackage, svc.Name()), + {Path: "goa.design/goa/v3/http", Name: "goahttp"}, + } + if serviceHasViewedResult(data, IsSSEEndpoint) { + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) + } + if serviceHasVariableViewedResult(data, IsSSEEndpoint) || serviceHasSSEResponseElements(data) { + imports = append(imports, codegen.GoaImport("")) + } sections = append(sections, codegen.Header( "sse-client", "client", - []*codegen.ImportSpec{ - {Path: "bytes"}, - {Path: "context"}, - {Path: "encoding/json"}, - {Path: "errors"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "fmt"}, - {Path: "strings"}, - {Path: "strconv"}, - {Path: "sync"}, - {Path: genpkg + "/" + codegen.SnakeCase(svc.Name()), Name: data.Service.PkgName}, - {Path: genpkg + "/" + codegen.SnakeCase(svc.Name()) + "/views", Name: data.Service.ViewsPkg}, - {Path: "goa.design/goa/v3/http", Name: "goahttp"}, - }, + imports, ), ) sections = append(sections, tmplSections...) // add SSE client methods @@ -50,17 +57,29 @@ func sseClientTemplateSections(data *ServiceData) []*codegen.SectionTemplate { if ed.SSE == nil { continue } + funcs := sseTemplateFuncs() + funcs["dict"] = dict + funcs["goTypeRef"] = func(dataType expr.DataType) string { + return data.Scope.GoTypeRef(&expr.AttributeExpr{Type: dataType}) + } sections = append(sections, &codegen.SectionTemplate{ - Name: "client-sse", - Source: httpTemplates.Read(clientSseT, sseParseP), - Data: ed, - FuncMap: map[string]any{ - "dict": dict, - "deref": func(ref string) string { - return strings.TrimPrefix(ref, "*") - }, - }, + Name: "client-sse", + Source: httpTemplates.Read(clientSseT, sseParseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP), + Data: ed, + FuncMap: funcs, }) } return sections } + +// serviceHasSSEResponseElements reports whether a stream constructor reads +// values from HTTP response headers or cookies in addition to event data. +func serviceHasSSEResponseElements(service *ServiceData) bool { + for _, endpoint := range service.Endpoints { + if endpoint.SSE != nil && endpoint.SSE.Response != nil && + (len(endpoint.SSE.Response.Headers) > 0 || len(endpoint.SSE.Response.Cookies) > 0) { + return true + } + } + return false +} diff --git a/http/codegen/sse_client_test.go b/http/codegen/sse_client_test.go index 7c52f71827..5df5a83cd1 100644 --- a/http/codegen/sse_client_test.go +++ b/http/codegen/sse_client_test.go @@ -30,8 +30,8 @@ func TestSSEClient(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 3) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) @@ -41,3 +41,62 @@ func TestSSEClient(t *testing.T) { }) } } + +// TestSSEClientSpecializesDataAndRetryParsing checks that generated clients +// parse each designed field into its exact Go type. +func TestSSEClientSpecializesDataAndRetryParsing(t *testing.T) { + tests := []struct { + name string + design func() + contains []string + }{ + { + name: "string alias", + design: ssePrimitiveAliasDSL, + contains: []string{"event = sseprimitivealias.EventText(dataContent)"}, + }, + { + name: "optional data field", + design: testdata.SSEDataFieldDSL, + contains: []string{ + "value := dataContent", + "event.Data = &value", + }, + }, + { + name: "viewed data field", + design: viewedSSEDataFieldDSL, + contains: []string{ + "value := dataContent", + "body.Data = &value", + }, + }, + { + name: "viewed alias data field", + design: viewedSSEPrimitiveAliasDataFieldDSL, + contains: []string{ + "value := viewedssealiasdata.ViewedEventText(dataContent)", + "body.Data = &value", + }, + }, + { + name: "retry", + design: testdata.SSEAllFieldsDSL, + contains: []string{ + `retryContent := s.trimHeader(line[len("retry:"):])`, + `strconv.ParseInt(retryContent, 10, 0)`, + "event.Retry = &value", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, test.design) + code := renderedFile(t, linkedHTTPPlanForRoot(t, root).ClientFiles()) + for _, expected := range test.contains { + require.Contains(t, code, expected) + } + require.NotContains(t, code, "retry value parsing depends on the field type") + }) + } +} diff --git a/http/codegen/sse_mixed_result_runtime_test.go b/http/codegen/sse_mixed_result_runtime_test.go new file mode 100644 index 0000000000..2f3c2daf1f --- /dev/null +++ b/http/codegen/sse_mixed_result_runtime_test.go @@ -0,0 +1,200 @@ +// This file renders a mixed HTTP/SSE client into a temporary module. The +// generated test proves mapped SSE fields are validated before the wire event +// becomes the service event returned by Recv. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedMixedSSEClientValidatesMappedWireBody catches clients decoding +// event data directly into a service value and skipping transport validation. +func TestGeneratedMixedSSEClientValidatesMappedWireBody(t *testing.T) { + root := expr.RunDSL(t, mixedSSEMappedFieldDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedMixedSSEClientTest(t, files) +} + +// TestGeneratedMixedSSEResultShapesCompile verifies each generation-time +// conversion branch produces complete client and server packages: direct +// primitive values, direct primitive collections, converted anonymous objects, +// and empty events. +func TestGeneratedMixedSSEResultShapesCompile(t *testing.T) { + root := expr.RunDSL(t, mixedSSEResultShapesDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].ServerFiles()...) + files = append(files, httpPlans[0].ServerTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedMixedSSECompile(t, files, "./gen/...") +} + +// mixedSSEResultShapesDSL puts every optional-transform shape in one generated +// client package so one compile checks their declarations and return paths. +func mixedSSEResultShapesDSL() { + dsl.Service("Mixed SSE Shapes", func() { + shapes := []struct { + name string + result any + }{ + {"int", dsl.Int}, + {"ints", dsl.ArrayOf(dsl.Int)}, + {"inline", func() { dsl.Attribute("value", dsl.Int) }}, + {"empty", func() {}}, + } + for _, shape := range shapes { + dsl.Method("watch_"+shape.name, func() { + dsl.Result(dsl.String) + dsl.StreamingResult(shape.result) + dsl.HTTP(func() { + dsl.GET("/" + shape.name) + dsl.ServerSentEvents() + }) + }) + } + }) +} + +// mixedSSEMappedFieldDSL makes event_id required and maps it to the SSE id +// line while message is carried by the data line. +func mixedSSEMappedFieldDSL() { + result := dsl.Type("Result", func() { + dsl.Attribute("event_id", dsl.String) + dsl.Attribute("message", dsl.String) + dsl.Required("event_id", "message") + }) + event := dsl.Type("Event", func() { + dsl.Attribute("event_id", dsl.String) + dsl.Attribute("message", dsl.String) + dsl.Required("event_id", "message") + }) + dsl.Service("Mixed SSE Wire", func() { + dsl.Method("watch", func() { + dsl.Result(result) + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("message", func() { + dsl.SSEEventID("event_id") + }) + }) + }) + }) +} + +// runGeneratedMixedSSEClientTest writes generated packages and runs the +// generated client's private event parser against complete and incomplete +// frames. +func runGeneratedMixedSSEClientTest(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + testPath := filepath.Join(directory, "gen", "http", "mixed_sse_wire", "client", "mixed_sse_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(generatedMixedSSEClientTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/mixed_sse_wire/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated mixed SSE client test:\n%s", output) +} + +// runGeneratedMixedSSECompile renders files in an isolated module and compiles +// the requested generated package. +func runGeneratedMixedSSECompile(t *testing.T, files []*codegen.File, packagePath string) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", packagePath) + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "compile generated mixed SSE clients:\n%s", output) +} + +const generatedMixedSSEClientTest = `package client + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMappedEventBodyIsValidated(t *testing.T) { + stream := &WatchStreamImpl{} + + event, err := stream.processEvent([]byte("id: event-1\ndata: ready\n\n")) + require.NoError(t, err) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "ready", event.Message) + + _, err = stream.processEvent([]byte("data: ready\n\n")) + require.Error(t, err) +} +` diff --git a/http/codegen/sse_mixed_results_test.go b/http/codegen/sse_mixed_results_test.go index 09422ccc85..e71a5c0025 100644 --- a/http/codegen/sse_mixed_results_test.go +++ b/http/codegen/sse_mixed_results_test.go @@ -8,16 +8,17 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" ) func TestSSE_MixedResults(t *testing.T) { root := expr.RunDSL(t, testdata.MixedResultsDSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) t.Run("server", func(t *testing.T) { - files := ServerFiles("", services) + files := plan.ServerFiles() var sseFile *codegen.File for _, f := range files { if strings.HasSuffix(f.Path, filepath.Join("server", "sse.go")) { @@ -31,12 +32,14 @@ func TestSSE_MixedResults(t *testing.T) { require.NotEmpty(t, sections) code := codegen.SectionCode(t, sections[0]) - require.Contains(t, code, "payload = res") - require.NotContains(t, code, "NewCreateResponseBody") + require.Contains(t, code, "body := NewEvent(res)") + require.Contains(t, code, "json.Marshal(body)") + require.NotContains(t, code, "var payload any") + require.NotContains(t, code, "json.Marshal(res)") }) t.Run("client", func(t *testing.T) { - files := ClientFiles("", services) + files := plan.ClientFiles() var sseFile *codegen.File for _, f := range files { if strings.HasSuffix(f.Path, filepath.Join("client", "sse.go")) { @@ -50,6 +53,68 @@ func TestSSE_MixedResults(t *testing.T) { require.NotEmpty(t, sections) code := codegen.SectionCode(t, sections[0]) - require.Contains(t, code, "event = new(") + require.Contains(t, code, "var body Event") + require.Contains(t, code, "err = ValidateEvent(&body)") + require.Contains(t, code, "result := &mixedresultsservice.Event{") + require.Contains(t, code, "return result, nil") }) } + +// TestSSE_MixedResultConversionSelection verifies that mixed SSE clients use a +// direct assignment only for wire values that already have the service type. +// Anonymous objects need a planned conversion, while an empty streamed result +// returns the method's zero event without declaring an HTTP body. +func TestSSE_MixedResultConversionSelection(t *testing.T) { + tests := []struct { + name string + streaming any + contains string + notContain string + }{ + {"primitive", dsl.Int, "result := body", "result := &"}, + {"primitive collection", dsl.ArrayOf(dsl.Int), "result := body", "result := &"}, + {"inline object", func() { dsl.Attribute("value", dsl.Int) }, "result := &", "result := body"}, + {"empty body", func() {}, "return event, nil", "var body"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, mixedSSEResultShapeDSL(test.streaming)) + plan := linkedHTTPPlanForRoot(t, root) + code := mixedSSEClientCode(t, plan) + require.Contains(t, code, test.contains) + require.NotContains(t, code, test.notContain) + }) + } +} + +// mixedSSEResultShapeDSL defines one ordinary string result and a separately +// streamed result so each test exercises the mixed SSE client path. +func mixedSSEResultShapeDSL(streaming any) func() { + return func() { + dsl.Service("Mixed Shape", func() { + dsl.Method("watch", func() { + dsl.Result(dsl.String) + dsl.StreamingResult(streaming) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) + } +} + +// mixedSSEClientCode renders the mixed endpoint's client stream implementation. +func mixedSSEClientCode(t *testing.T, plan *Plan) string { + t.Helper() + for _, file := range plan.ClientFiles() { + if !strings.HasSuffix(file.Path, filepath.Join("client", "sse.go")) { + continue + } + sections := file.Section("client-sse") + require.NotEmpty(t, sections) + return codegen.SectionCode(t, sections[0]) + } + t.Fatal("mixed SSE client file was not generated") + return "" +} diff --git a/http/codegen/sse_primitive_wire_runtime_test.go b/http/codegen/sse_primitive_wire_runtime_test.go new file mode 100644 index 0000000000..6c584e7381 --- /dev/null +++ b/http/codegen/sse_primitive_wire_runtime_test.go @@ -0,0 +1,250 @@ +// This file renders an HTTP SSE service into a temporary module. The generated +// server and client tests check the exact text used for primitive fields and +// the JSON used for structured fields. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedSSEFieldWireFormat checks both sides of the generated SSE +// connection for primitive, declared primitive, object, and array fields. +func TestGeneratedSSEFieldWireFormat(t *testing.T) { + root := expr.RunDSL(t, sseFieldWireDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ServerFiles()...) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ServerTypeFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedSSEFieldWireTests(t, files) +} + +// sseFieldWireDSL maps each representative field type to the SSE data line. +func sseFieldWireDSL() { + eventText := dsl.Type("EventText", dsl.String) + requiredText := dsl.Type("RequiredText", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + aliasText := dsl.Type("AliasText", func() { + dsl.Attribute("value", eventText) + dsl.Required("value") + }) + optionalText := dsl.Type("OptionalText", func() { + dsl.Attribute("value", dsl.String) + }) + wireObject := dsl.Type("WireObject", func() { + dsl.Attribute("label", dsl.String) + dsl.Required("label") + }) + structured := dsl.Type("Structured", func() { + dsl.Attribute("object", wireObject) + dsl.Attribute("values", dsl.ArrayOf(dsl.String)) + dsl.Required("object", "values") + }) + + dsl.Service("SSE Wire", func() { + dsl.Method("required", func() { + dsl.StreamingResult(requiredText) + dsl.HTTP(func() { + dsl.GET("/required") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("alias", func() { + dsl.StreamingResult(aliasText) + dsl.HTTP(func() { + dsl.GET("/alias") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("optional", func() { + dsl.StreamingResult(optionalText) + dsl.HTTP(func() { + dsl.GET("/optional") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("object", func() { + dsl.StreamingResult(structured) + dsl.HTTP(func() { + dsl.GET("/object") + dsl.ServerSentEvents("object") + }) + }) + dsl.Method("array", func() { + dsl.StreamingResult(structured) + dsl.HTTP(func() { + dsl.GET("/array") + dsl.ServerSentEvents("values") + }) + }) + }) +} + +// runGeneratedSSEFieldWireTests writes the generated packages and executes the +// server and client tests inside them. +func runGeneratedSSEFieldWireTests(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + serverTest := filepath.Join(directory, "gen", "http", "sse_wire", "server", "wire_test.go") + clientTest := filepath.Join(directory, "gen", "http", "sse_wire", "client", "wire_test.go") + require.NoError(t, os.WriteFile(serverTest, []byte(generatedSSEServerWireTest), 0o600)) + require.NoError(t, os.WriteFile(clientTest, []byte(generatedSSEClientWireTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/sse_wire/server", "./gen/http/sse_wire/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated SSE wire tests:\n%s", output) +} + +const generatedSSEServerWireTest = `package server + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_wire" +) + +func TestPrimitiveFieldsUseRawSSEData(t *testing.T) { + tests := []struct { + name string + send func() string + want string + }{ + { + name: "string", + send: func() string { + recorder := httptest.NewRecorder() + stream := &RequiredServerStream{w: recorder} + require.NoError(t, stream.Send(&service.RequiredText{Value: "event"})) + return recorder.Body.String() + }, + want: "data: event\n\n", + }, + { + name: "string alias", + send: func() string { + recorder := httptest.NewRecorder() + stream := &AliasServerStream{w: recorder} + require.NoError(t, stream.Send(&service.AliasText{Value: service.EventText("event")})) + return recorder.Body.String() + }, + want: "data: event\n\n", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, test.send()) + }) + } +} + +func TestOptionalPrimitiveFieldPreservesPresence(t *testing.T) { + tests := []struct { + name string + value *string + want string + }{ + {name: "value", value: stringPointer("event"), want: "data: event\n\n"}, + {name: "empty", value: stringPointer(""), want: "data: \n\n"}, + {name: "absent", want: "\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + stream := &OptionalServerStream{w: recorder} + require.NoError(t, stream.Send(&service.OptionalText{Value: test.value})) + require.Equal(t, test.want, recorder.Body.String()) + }) + } +} + +func TestStructuredFieldsUseJSON(t *testing.T) { + objectRecorder := httptest.NewRecorder() + objectStream := &ObjectServerStream{w: objectRecorder} + value := &service.WireObject{Label: "event"} + require.NoError(t, objectStream.Send(&service.Structured{Object: value, Values: []string{"one", "two"}})) + require.Equal(t, "data: {\"label\":\"event\"}\n\n", objectRecorder.Body.String()) + + arrayRecorder := httptest.NewRecorder() + arrayStream := &ArrayServerStream{w: arrayRecorder} + require.NoError(t, arrayStream.Send(&service.Structured{Object: value, Values: []string{"one", "two"}})) + require.Equal(t, "data: [\"one\",\"two\"]\n\n", arrayRecorder.Body.String()) +} + +func stringPointer(value string) *string { + return &value +} +` + +const generatedSSEClientWireTest = `package client + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOptionalPrimitiveDataAllocatesOnlyWhenPresent(t *testing.T) { + stream := &OptionalStreamImpl{} + + value, err := stream.processEvent([]byte("data: event\n\n")) + require.NoError(t, err) + require.NotNil(t, value.Value) + require.Equal(t, "event", *value.Value) + + empty, err := stream.processEvent([]byte("data: \n\n")) + require.NoError(t, err) + require.NotNil(t, empty.Value) + require.Empty(t, *empty.Value) + + absent, err := stream.processEvent([]byte("\n\n")) + require.NoError(t, err) + require.Nil(t, absent.Value) +} +` diff --git a/http/codegen/sse_server_test.go b/http/codegen/sse_server_test.go index 7a32e2a084..2586091fa3 100644 --- a/http/codegen/sse_server_test.go +++ b/http/codegen/sse_server_test.go @@ -8,6 +8,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" ) @@ -30,8 +31,8 @@ func TestSSE(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 3) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) @@ -42,10 +43,50 @@ func TestSSE(t *testing.T) { } } +// TestSSEServerSpecializesDataEncoding checks that generated send methods use +// the designed data type directly, including named primitive types. +func TestSSEServerSpecializesDataEncoding(t *testing.T) { + tests := []struct { + name string + design func() + contains string + }{ + {name: "string", design: testdata.SSEStringDSL, contains: "data = string(body)"}, + {name: "string alias", design: ssePrimitiveAliasDSL, contains: "data = string(body)"}, + {name: "object", design: testdata.SSEObjectDSL, contains: "json.Marshal(body)"}, + {name: "optional data field", design: testdata.SSEDataFieldDSL, contains: "data = string(*body.Data)"}, + {name: "viewed data field", design: viewedSSEDataFieldDSL, contains: "data = string(body.Data)"}, + {name: "viewed alias data field", design: viewedSSEPrimitiveAliasDataFieldDSL, contains: "data = string(body.Data)"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, test.design) + code := renderedFile(t, linkedHTTPPlanForRoot(t, root).ServerFiles()) + + require.Contains(t, code, test.contains) + require.NotContains(t, code, "var payload any") + require.NotContains(t, code, "payload.(type)") + if test.name == "optional data field" { + require.Contains(t, code, "if body.Data != nil") + require.NotContains(t, code, "json.Marshal(body.Data)") + } + }) + } +} + +// TestSSEServerWritesOptionalRetryValue checks that an optional service field +// is tested and dereferenced before it is written to the retry line. +func TestSSEServerWritesOptionalRetryValue(t *testing.T) { + root := expr.RunDSL(t, testdata.SSEAllFieldsDSL) + code := renderedFile(t, linkedHTTPPlanForRoot(t, root).ServerFiles()) + require.Contains(t, code, "retry != nil && *retry > 0") + require.Contains(t, code, `fmt.Fprintf(s.w, "retry: %d\n", *retry)`) +} + func TestSSETransportDefaultsToStatusOK(t *testing.T) { root := expr.RunDSL(t, testdata.SSEStringDSL) - services := CreateHTTPServices(root) - fs := ServerFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 3) sections := fs[1].SectionTemplates @@ -54,3 +95,18 @@ func TestSSETransportDefaultsToStatusOK(t *testing.T) { require.Contains(t, code, "s.w.WriteHeader(http.StatusOK)") require.NotContains(t, code, "http.StatusSwitchingProtocols") } + +// ssePrimitiveAliasDSL streams a named string so generated SSE code must use +// its known underlying string representation. +func ssePrimitiveAliasDSL() { + text := dsl.Type("EventText", dsl.String) + dsl.Service("SSE Primitive Alias", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(text) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} diff --git a/http/codegen/streaming_test.go b/http/codegen/streaming_test.go index 996ef20587..93f3acf66c 100644 --- a/http/codegen/streaming_test.go +++ b/http/codegen/streaming_test.go @@ -1,6 +1,7 @@ package codegen import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -108,7 +109,7 @@ func TestServerStreaming(t *testing.T) { {"server-websocket-send", &testdata.StreamingPayloadResultWithViewsServerStreamSendCode}, {"server-websocket-recv", &testdata.StreamingPayloadResultWithViewsServerStreamRecvCode}, {"server-websocket-close", nil}, - {"server-websocket-set-view", &testdata.StreamingPayloadResultWithViewsServerStreamSetViewCode}, + {"server-websocket-set-view", nil}, }}, {"streaming-payload-result-with-explicit-view", testdata.StreamingPayloadResultWithExplicitViewDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.StreamingPayloadResultWithExplicitViewServerStreamSendCode}, @@ -118,7 +119,7 @@ func TestServerStreaming(t *testing.T) { {"streaming-payload-result-collection-with-views", testdata.StreamingPayloadResultCollectionWithViewsDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.StreamingPayloadResultCollectionWithViewsServerStreamSendCode}, {"server-websocket-recv", &testdata.StreamingPayloadResultCollectionWithViewsServerStreamRecvCode}, - {"server-websocket-set-view", &testdata.StreamingPayloadResultCollectionWithViewsServerStreamSetViewCode}, + {"server-websocket-set-view", nil}, }}, {"streaming-payload-result-collection-with-explicit-view", testdata.StreamingPayloadResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.StreamingPayloadResultCollectionWithExplicitViewServerStreamSendCode}, @@ -164,7 +165,7 @@ func TestServerStreaming(t *testing.T) { {"server-websocket-send", &testdata.BidirectionalStreamingResultWithViewsServerStreamSendCode}, {"server-websocket-recv", &testdata.BidirectionalStreamingResultWithViewsServerStreamRecvCode}, {"server-websocket-close", &testdata.BidirectionalStreamingResultWithViewsServerStreamCloseCode}, - {"server-websocket-set-view", &testdata.BidirectionalStreamingResultWithViewsServerStreamSetViewCode}, + {"server-websocket-set-view", nil}, }}, {"bidirectional-streaming-result-with-explicit-view", testdata.BidirectionalStreamingResultWithExplicitViewDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.BidirectionalStreamingResultWithExplicitViewServerStreamSendCode}, @@ -174,7 +175,7 @@ func TestServerStreaming(t *testing.T) { {"bidirectional-streaming-result-collection-with-views", testdata.BidirectionalStreamingResultCollectionWithViewsDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.BidirectionalStreamingResultCollectionWithViewsServerStreamSendCode}, {"server-websocket-recv", &testdata.BidirectionalStreamingResultCollectionWithViewsServerStreamRecvCode}, - {"server-websocket-set-view", &testdata.BidirectionalStreamingResultCollectionWithViewsServerStreamSetViewCode}, + {"server-websocket-set-view", nil}, }}, {"bidirectional-streaming-result-collection-with-explicit-view", testdata.BidirectionalStreamingResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.BidirectionalStreamingResultCollectionWithExplicitViewServerStreamSendCode}, @@ -205,12 +206,41 @@ func TestServerStreaming(t *testing.T) { } filesFn := func(root *expr.RootExpr) []*codegen.File { - services := CreateHTTPServices(root) - return ServerFiles("", services) + return linkedHTTPPlanForRoot(t, root).ServerFiles() } runTests(t, cases, filesFn) } +// TestVariableViewWebSocketSendWritesTypedBranches checks that the selected +// view is the only runtime choice and that an unknown view writes nothing. +func TestVariableViewWebSocketSendWritesTypedBranches(t *testing.T) { + root := expr.RunDSL(t, testdata.StreamingResultWithViewsDSL) + files := linkedHTTPPlanForRoot(t, root).ServerFiles() + var code string + for _, file := range files { + for _, section := range file.SectionTemplates { + if section.Name == "server-websocket-send" { + code = codegen.SectionCode(t, section) + } + } + } + require.NotEmpty(t, code) + require.NotContains(t, code, "var body any") + require.Contains(t, code, `if view == "" {`) + require.Contains(t, code, `view = "default"`) + require.Contains(t, code, `if s.sentView != "" && view != s.sentView`) + require.Contains(t, code, `respHdr.Add("goa-view", view)`) + require.Contains(t, code, `case "tiny":`) + require.Contains(t, code, `res := streamingresultwithviewsservice.NewViewedUsertype(v, "tiny")`) + require.Contains(t, code, "return s.conn.WriteJSON(NewStreamingResultWithViewsMethodResponseBodyTiny(res.Projected))") + require.Contains(t, code, `default:`) + require.Contains(t, code, `return goa.InvalidEnumValueError("view", view`) + require.Less(t, + strings.Index(code, `InvalidEnumValueError("view", view`), + strings.Index(code, "s.once.Do"), + ) +} + func TestClientStreaming(t *testing.T) { cases := []*testCase{ {"client-mixed-endpoints", testdata.StreamingResultDSL, []*sectionExpectation{ @@ -290,7 +320,7 @@ func TestClientStreaming(t *testing.T) { {"client-websocket-send", &testdata.StreamingPayloadResultWithViewsClientStreamSendCode}, {"client-websocket-recv", &testdata.StreamingPayloadResultWithViewsClientStreamRecvCode}, {"client-websocket-close", nil}, - {"client-websocket-set-view", &testdata.StreamingPayloadResultWithViewsClientStreamSetViewCode}, + {"client-websocket-set-view", nil}, }}, {"client-streaming-payload-result-with-explicit-view", testdata.StreamingPayloadResultWithExplicitViewDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.StreamingPayloadResultWithExplicitViewClientStreamSendCode}, @@ -300,7 +330,7 @@ func TestClientStreaming(t *testing.T) { {"client-streaming-payload-result-collection-with-views", testdata.StreamingPayloadResultCollectionWithViewsDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.StreamingPayloadResultCollectionWithViewsClientStreamSendCode}, {"client-websocket-recv", &testdata.StreamingPayloadResultCollectionWithViewsClientStreamRecvCode}, - {"client-websocket-set-view", &testdata.StreamingPayloadResultCollectionWithViewsClientStreamSetViewCode}, + {"client-websocket-set-view", nil}, }}, {"client-streaming-payload-result-collection-with-explicit-view", testdata.StreamingPayloadResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.StreamingPayloadResultCollectionWithExplicitViewClientStreamSendCode}, @@ -348,7 +378,7 @@ func TestClientStreaming(t *testing.T) { {"client-websocket-send", &testdata.BidirectionalStreamingResultWithViewsClientStreamSendCode}, {"client-websocket-recv", &testdata.BidirectionalStreamingResultWithViewsClientStreamRecvCode}, {"client-websocket-close", &testdata.BidirectionalStreamingResultWithViewsClientStreamCloseCode}, - {"client-websocket-set-view", &testdata.BidirectionalStreamingResultWithViewsClientStreamSetViewCode}, + {"client-websocket-set-view", nil}, }}, {"client-bidirectional-streaming-result-with-explicit-view", testdata.BidirectionalStreamingResultWithExplicitViewDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.BidirectionalStreamingResultWithExplicitViewClientStreamSendCode}, @@ -358,7 +388,7 @@ func TestClientStreaming(t *testing.T) { {"client-bidirectional-streaming-result-collection-with-views", testdata.BidirectionalStreamingResultCollectionWithViewsDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.BidirectionalStreamingResultCollectionWithViewsClientStreamSendCode}, {"client-websocket-recv", &testdata.BidirectionalStreamingResultCollectionWithViewsClientStreamRecvCode}, - {"client-websocket-set-view", &testdata.BidirectionalStreamingResultCollectionWithViewsClientStreamSetViewCode}, + {"client-websocket-set-view", nil}, }}, {"client-bidirectional-streaming-result-collection-with-explicit-view", testdata.BidirectionalStreamingResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.BidirectionalStreamingResultCollectionWithExplicitViewClientStreamSendCode}, @@ -388,8 +418,7 @@ func TestClientStreaming(t *testing.T) { }}, } filesFn := func(root *expr.RootExpr) []*codegen.File { - services := CreateHTTPServices(root) - return ClientFiles("", services) + return linkedHTTPPlanForRoot(t, root).ClientFiles() } runTests(t, cases, filesFn) } diff --git a/http/codegen/symbols.go b/http/codegen/symbols.go new file mode 100644 index 0000000000..19a5be127e --- /dev/null +++ b/http/codegen/symbols.go @@ -0,0 +1,402 @@ +// This file requests the Go names written by generated HTTP client and server +// files. NewPlans calls it before names are assigned, and Link later gives the +// same records to every definition and use. +package codegen + +import ( + "cmp" + "strconv" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // httpSymbols contains every package-level name emitted for one HTTP service. + httpSymbols struct { + serverStruct *codegen.NameDeclaration + mountPoint *codegen.NameDeclaration + serverInit *codegen.NameDeclaration + mountServer *codegen.NameDeclaration + clientStruct *codegen.NameDeclaration + clientInit *codegen.NameDeclaration + serverConfigurer *codegen.NameDeclaration + serverConfigurerInit *codegen.NameDeclaration + clientConfigurer *codegen.NameDeclaration + clientConfigurerInit *codegen.NameDeclaration + appendFS *codegen.NameDeclaration + appendPrefix *codegen.NameDeclaration + endpoints map[*expr.HTTPEndpointExpr]*httpEndpointSymbols + fileServers map[*expr.HTTPFileServerExpr]*codegen.NameDeclaration + } + + // httpEndpointSymbols contains the package-level names emitted for one endpoint. + httpEndpointSymbols struct { + mountHandler *codegen.NameDeclaration + handlerInit *codegen.NameDeclaration + requestDecoder *codegen.NameDeclaration + responseEncoder *codegen.NameDeclaration + errorEncoder *codegen.NameDeclaration + discardStream *codegen.NameDeclaration + requestEncoder *codegen.NameDeclaration + responseDecoder *codegen.NameDeclaration + requestBuilder *codegen.NameDeclaration + buildStreamPayload *codegen.NameDeclaration + cliPayload *codegen.NameDeclaration + serverMultipart *httpMultipartSymbols + clientMultipart *httpMultipartSymbols + serverStream *codegen.NameDeclaration + clientStream *codegen.NameDeclaration + sseClientInterface *codegen.NameDeclaration + sseClientStruct *codegen.NameDeclaration + sseClientInit *codegen.NameDeclaration + serverPaths []*codegen.NameDeclaration + clientPaths []*codegen.NameDeclaration + } + + // httpMultipartSymbols contains the type and constructor names for one side + // of a multipart endpoint. + httpMultipartSymbols struct { + functionType *codegen.NameDeclaration + constructor *codegen.NameDeclaration + } + + // httpSymbolID identifies one emitted declaration without encoding fields in + // a string. The output package is supplied separately by the caller. + httpSymbolID struct { + transport transportKind + role httpSymbolRole + api string + service string + method string + subject string + index int + } + + // httpSymbolOrder gives colliding declarations the same result regardless of + // the order in which design roots are passed to NewPlans. + httpSymbolOrder httpSymbolID + + // httpSymbolRole lists each package declaration emitted outside HTTP body + // type and constructor files. + httpSymbolRole uint8 +) + +const ( + httpServerStructRole httpSymbolRole = iota + 1 + httpMountPointRole + httpServerInitRole + httpMountServerRole + httpClientStructRole + httpClientInitRole + httpConnConfigurerRole + httpConnConfigurerInitRole + httpAppendFSRole + httpAppendPrefixRole + httpMountHandlerRole + httpHandlerInitRole + httpRequestDecoderRole + httpResponseEncoderRole + httpErrorEncoderRole + httpDiscardStreamRole + httpRequestEncoderRole + httpResponseDecoderRole + httpRequestBuilderRole + httpBuildStreamPayloadRole + httpCLIPayloadRole + httpMultipartTypeRole + httpMultipartInitRole + httpServerStreamRole + httpClientStreamRole + httpSSEClientInterfaceRole + httpSSEClientStructRole + httpSSEClientInitRole + httpPathRole + httpFileMountRole +) + +// collectHTTPSymbols requests each client and server name needed by service. It +// returns records that Link gives to definitions and calls after Goa assigns +// the names. +func collectHTTPSymbols(plan *Plan, service *expr.HTTPServiceExpr, clientPackage, serverPackage *codegen.GeneratedPackage) (*httpSymbols, error) { + symbols := &httpSymbols{ + endpoints: make(map[*expr.HTTPEndpointExpr]*httpEndpointSymbols), + fileServers: make(map[*expr.HTTPFileServerExpr]*codegen.NameDeclaration), + } + declare := func(pkg *codegen.GeneratedPackage, kind codegen.PackageNameKind, preferred string, exported codegen.PackageNameVisibility, id httpSymbolID) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(kind, preferred, exported, httpSymbolOrder(id)) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil + } + serviceID := httpSymbolID{transport: plan.transport, api: plan.root.API.Name, service: service.Name()} + var err error + if symbols.serverStruct, err = declare(serverPackage, codegen.NameType, "Server", codegen.ExportedName, serviceID.withRole(httpServerStructRole)); err != nil { + return nil, err + } + if symbols.mountPoint, err = declare(serverPackage, codegen.NameType, "MountPoint", codegen.ExportedName, serviceID.withRole(httpMountPointRole)); err != nil { + return nil, err + } + if symbols.serverInit, err = declare(serverPackage, codegen.NameFunction, "New", codegen.ExportedName, serviceID.withRole(httpServerInitRole)); err != nil { + return nil, err + } + if symbols.mountServer, err = declare(serverPackage, codegen.NameFunction, "Mount", codegen.ExportedName, serviceID.withRole(httpMountServerRole)); err != nil { + return nil, err + } + if symbols.clientStruct, err = declare(clientPackage, codegen.NameType, "Client", codegen.ExportedName, serviceID.withRole(httpClientStructRole)); err != nil { + return nil, err + } + if symbols.clientInit, err = declare(clientPackage, codegen.NameFunction, "NewClient", codegen.ExportedName, serviceID.withRole(httpClientInitRole)); err != nil { + return nil, err + } + + hasWebSocket := false + for _, endpoint := range service.HTTPEndpoints { + if endpoint.UsesWebSocket() { + hasWebSocket = true + break + } + } + if hasWebSocket { + if symbols.serverConfigurer, err = declare(serverPackage, codegen.NameType, "ConnConfigurer", codegen.ExportedName, serviceID.withRole(httpConnConfigurerRole).withSubject("server")); err != nil { + return nil, err + } + if symbols.serverConfigurerInit, err = declare(serverPackage, codegen.NameFunction, "NewConnConfigurer", codegen.ExportedName, serviceID.withRole(httpConnConfigurerInitRole).withSubject("server")); err != nil { + return nil, err + } + if symbols.clientConfigurer, err = declare(clientPackage, codegen.NameType, "ConnConfigurer", codegen.ExportedName, serviceID.withRole(httpConnConfigurerRole).withSubject("client")); err != nil { + return nil, err + } + if symbols.clientConfigurerInit, err = declare(clientPackage, codegen.NameFunction, "NewConnConfigurer", codegen.ExportedName, serviceID.withRole(httpConnConfigurerInitRole).withSubject("client")); err != nil { + return nil, err + } + } + if len(service.FileServers) > 0 { + if symbols.appendFS, err = declare(serverPackage, codegen.NameType, "appendFS", codegen.UnexportedName, serviceID.withRole(httpAppendFSRole)); err != nil { + return nil, err + } + if symbols.appendPrefix, err = declare(serverPackage, codegen.NameFunction, "appendPrefix", codegen.UnexportedName, serviceID.withRole(httpAppendPrefixRole)); err != nil { + return nil, err + } + } + for index, fileServer := range service.FileServers { + id := serviceID.withRole(httpFileMountRole).withSubject(fileServer.FilePath).withIndex(index) + declaration, err := declare(serverPackage, codegen.NameFunction, "Mount"+codegen.Goify(fileServer.FilePath, true), codegen.ExportedName, id) + if err != nil { + return nil, err + } + symbols.fileServers[fileServer] = declaration + } + for _, endpoint := range service.HTTPEndpoints { + names, err := plan.servicePlan.HTTPMethodNames(endpoint.MethodExpr) + if err != nil { + return nil, err + } + id := serviceID.withMethod(endpoint.MethodExpr.Name) + endpointSymbols := &httpEndpointSymbols{} + endpointSymbols.mountHandler, err = declare(serverPackage, codegen.NameFunction, "Mount"+names.Method+"Handler", codegen.ExportedName, id.withRole(httpMountHandlerRole)) + if err != nil { + return nil, err + } + endpointSymbols.handlerInit, err = declare(serverPackage, codegen.NameFunction, "New"+names.Method+"Handler", codegen.ExportedName, id.withRole(httpHandlerInitRole)) + if err != nil { + return nil, err + } + if endpoint.MethodExpr.Payload.Type != expr.Empty { + endpointSymbols.requestDecoder, err = declare(serverPackage, codegen.NameFunction, "Decode"+names.Method+"Request", codegen.ExportedName, id.withRole(httpRequestDecoderRole)) + if err != nil { + return nil, err + } + } + if endpoint.Redirect == nil && !endpoint.UsesWebSocket() && !endpoint.IsJSONRPC() { + endpointSymbols.responseEncoder, err = declare(serverPackage, codegen.NameFunction, "Encode"+names.Method+"Response", codegen.ExportedName, id.withRole(httpResponseEncoderRole)) + if err != nil { + return nil, err + } + } + if len(endpoint.HTTPErrors) > 0 && !endpoint.IsJSONRPC() { + endpointSymbols.errorEncoder, err = declare(serverPackage, codegen.NameFunction, "Encode"+names.Method+"Error", codegen.ExportedName, id.withRole(httpErrorEncoderRole)) + if err != nil { + return nil, err + } + } + if endpoint.MethodExpr.HasMixedResults() { + endpointSymbols.discardStream, err = declare(serverPackage, codegen.NameType, "discard"+names.Method+"ServerStream", codegen.UnexportedName, id.withRole(httpDiscardStreamRole)) + if err != nil { + return nil, err + } + } + if clientRequestEncoderSelected(endpoint) { + endpointSymbols.requestEncoder, err = declare(clientPackage, codegen.NameFunction, "Encode"+names.Method+"Request", codegen.ExportedName, id.withRole(httpRequestEncoderRole)) + if err != nil { + return nil, err + } + } + endpointSymbols.responseDecoder, err = declare(clientPackage, codegen.NameFunction, "Decode"+names.Method+"Response", codegen.ExportedName, id.withRole(httpResponseDecoderRole)) + if err != nil { + return nil, err + } + endpointSymbols.requestBuilder, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"Request", codegen.ExportedName, id.withRole(httpRequestBuilderRole)) + if err != nil { + return nil, err + } + if endpoint.SkipRequestBodyEncodeDecode { + endpointSymbols.buildStreamPayload, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"StreamPayload", codegen.ExportedName, id.withRole(httpBuildStreamPayloadRole)) + if err != nil { + return nil, err + } + } + if needInit(endpoint.MethodExpr.Payload.Type) { + endpointSymbols.cliPayload, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"Payload", codegen.ExportedName, id.withRole(httpCLIPayloadRole)) + if err != nil { + return nil, err + } + } + if endpoint.MultipartRequest { + serviceName := codegen.Goify(service.Name(), true) + endpointSymbols.serverMultipart, err = declareHTTPMultipart(declare, serverPackage, serviceName+names.Method+"DecoderFunc", "New"+serviceName+names.Method+"Decoder", id.withSubject("server")) + if err != nil { + return nil, err + } + endpointSymbols.clientMultipart, err = declareHTTPMultipart(declare, clientPackage, serviceName+names.Method+"EncoderFunc", "New"+serviceName+names.Method+"Encoder", id.withSubject("client")) + if err != nil { + return nil, err + } + } + if endpoint.UsesWebSocket() { + endpointSymbols.serverStream, err = declare(serverPackage, codegen.NameType, names.ServerStream, codegen.ExportedName, id.withRole(httpServerStreamRole)) + if err != nil { + return nil, err + } + endpointSymbols.clientStream, err = declare(clientPackage, codegen.NameType, names.ClientStream, codegen.ExportedName, id.withRole(httpClientStreamRole)) + if err != nil { + return nil, err + } + } + if endpoint.UsesSSE() { + endpointSymbols.serverStream, err = declare(serverPackage, codegen.NameType, names.ServerStream, codegen.ExportedName, id.withRole(httpServerStreamRole)) + if err != nil { + return nil, err + } + endpointSymbols.sseClientInterface, err = declare(clientPackage, codegen.NameType, names.Method+"ClientStream", codegen.ExportedName, id.withRole(httpSSEClientInterfaceRole)) + if err != nil { + return nil, err + } + endpointSymbols.sseClientStruct, err = declare(clientPackage, codegen.NameType, names.Method+"StreamImpl", codegen.ExportedName, id.withRole(httpSSEClientStructRole)) + if err != nil { + return nil, err + } + endpointSymbols.sseClientInit, err = declare(clientPackage, codegen.NameFunction, "New"+names.Method+"Stream", codegen.ExportedName, id.withRole(httpSSEClientInitRole)) + if err != nil { + return nil, err + } + } + pathCount := 0 + for _, route := range endpoint.Routes { + for range route.FullPaths() { + suffix := "" + if pathCount > 0 { + suffix = strconv.Itoa(pathCount + 1) + } + preferred := names.Method + codegen.Goify(service.Name(), true) + "Path" + suffix + pathID := id.withRole(httpPathRole).withIndex(pathCount) + serverPath, err := declare(serverPackage, codegen.NameFunction, preferred, codegen.ExportedName, pathID.withSubject("server")) + if err != nil { + return nil, err + } + clientPath, err := declare(clientPackage, codegen.NameFunction, preferred, codegen.ExportedName, pathID.withSubject("client")) + if err != nil { + return nil, err + } + endpointSymbols.serverPaths = append(endpointSymbols.serverPaths, serverPath) + endpointSymbols.clientPaths = append(endpointSymbols.clientPaths, clientPath) + pathCount++ + } + } + symbols.endpoints[endpoint] = endpointSymbols + } + return symbols, nil +} + +// declareHTTPMultipart requests the type and constructor emitted for one +// multipart endpoint side. +func declareHTTPMultipart(declare func(*codegen.GeneratedPackage, codegen.PackageNameKind, string, codegen.PackageNameVisibility, httpSymbolID) (*codegen.NameDeclaration, error), pkg *codegen.GeneratedPackage, typeName, initName string, id httpSymbolID) (*httpMultipartSymbols, error) { + functionType, err := declare(pkg, codegen.NameType, typeName, codegen.ExportedName, id.withRole(httpMultipartTypeRole)) + if err != nil { + return nil, err + } + constructor, err := declare(pkg, codegen.NameFunction, initName, codegen.ExportedName, id.withRole(httpMultipartInitRole)) + if err != nil { + return nil, err + } + return &httpMultipartSymbols{functionType: functionType, constructor: constructor}, nil +} + +// clientRequestEncoderSelected reports whether the client codec file writes a +// request encoder for endpoint. +func clientRequestEncoderSelected(endpoint *expr.HTTPEndpointExpr) bool { + if endpoint.IsJSONRPC() { + return true + } + if (!endpoint.SkipRequestBodyEncodeDecode && endpoint.Body.Type != expr.Empty) || + endpoint.MapQueryParams != nil || + len(*expr.AsObject(endpoint.QueryParams().Type)) > 0 || + len(*expr.AsObject(endpoint.Headers.Type)) > 0 || + len(*expr.AsObject(endpoint.Cookies.Type)) > 0 { + return true + } + for _, requirement := range endpoint.Requirements { + for _, scheme := range requirement.Schemes { + if scheme.Kind == expr.BasicAuthKind { + return true + } + } + } + return false +} + +// withRole returns id with the declaration role used by one template. +func (id httpSymbolID) withRole(role httpSymbolRole) httpSymbolID { + id.role = role + return id +} + +// withMethod returns id with the design method that emits the declaration. +func (id httpSymbolID) withMethod(method string) httpSymbolID { + id.method = method + return id +} + +// withSubject returns id with the route side or file path that distinguishes +// otherwise identical declarations. +func (id httpSymbolID) withSubject(subject string) httpSymbolID { + id.subject = subject + return id +} + +// withIndex returns id with the route or file position in its design list. +func (id httpSymbolID) withIndex(index int) httpSymbolID { + id.index = index + return id +} + +// ComparePackageName orders HTTP declarations by stable design values. +func (order httpSymbolOrder) ComparePackageName(other codegen.PackageNameOrder) int { + left := httpSymbolID(order) + right := httpSymbolID(other.(httpSymbolOrder)) + for _, compared := range []int{ + cmp.Compare(left.transport, right.transport), + cmp.Compare(left.api, right.api), + cmp.Compare(left.service, right.service), + cmp.Compare(left.method, right.method), + cmp.Compare(left.role, right.role), + cmp.Compare(left.subject, right.subject), + cmp.Compare(left.index, right.index), + } { + if compared != 0 { + return compared + } + } + return 0 +} diff --git a/http/codegen/templates.go b/http/codegen/templates.go index f625416473..60abee76e5 100644 --- a/http/codegen/templates.go +++ b/http/codegen/templates.go @@ -96,6 +96,7 @@ const ( sseParseP = "sse_parse" websocketUpgradeP = "websocket_upgrade" clientTypeConversionP = "client_type_conversion" + clientTypeExpressionP = "client_type_expression" clientMapConversionP = "client_map_conversion" singleResponseP = "single_response" queryTypeConversionP = "query_type_conversion" diff --git a/http/codegen/templates/append_fs.go.tpl b/http/codegen/templates/append_fs.go.tpl index 0ae3643c9a..ce1b0caf7d 100644 --- a/http/codegen/templates/append_fs.go.tpl +++ b/http/codegen/templates/append_fs.go.tpl @@ -1,15 +1,14 @@ -// appendFS is a custom implementation of fs.FS that appends a specified prefix -// to the file paths before delegating the Open call to the underlying fs.FS. -type appendFS struct { +{{ printf "%s adds a fixed directory to file paths before opening them." .AppendFSDeclaration.Name | comment }} +type {{ .AppendFSDeclaration.Name }} struct { prefix string fs http.FileSystem } // Open opens the named file, appending the prefix to the file path before -// passing it to the underlying fs.FS. -func (s appendFS) Open(name string) (http.File, error) { +// passing it to the underlying file system. +func (s {{ .AppendFSDeclaration.Name }}) Open(name string) (http.File, error) { switch name { - {{- range $requested, $embedded := . }} + {{- range $requested, $embedded := .Mappings }} case {{ printf "%q" $requested }}: name = {{ printf "%q" $embedded }} {{- end }} @@ -17,8 +16,7 @@ func (s appendFS) Open(name string) (http.File, error) { return s.fs.Open(path.Join(s.prefix, name)) } -// appendPrefix returns a new fs.FS that appends the specified prefix to file paths -// before delegating to the provided embed.FS. -func appendPrefix(fsys http.FileSystem, prefix string) http.FileSystem { - return appendFS{prefix: prefix, fs: fsys} +{{ printf "%s returns a file system that adds prefix before opening each path." .AppendPrefixDeclaration.Name | comment }} +func {{ .AppendPrefixDeclaration.Name }}(fsys http.FileSystem, prefix string) http.FileSystem { + return {{ .AppendFSDeclaration.Name }}{prefix: prefix, fs: fsys} } diff --git a/http/codegen/templates/build_stream_request.go.tpl b/http/codegen/templates/build_stream_request.go.tpl index 2cab16afc5..2101f434c3 100644 --- a/http/codegen/templates/build_stream_request.go.tpl +++ b/http/codegen/templates/build_stream_request.go.tpl @@ -1,10 +1,10 @@ -// {{ printf "%s creates a streaming endpoint request payload from the method payload and the path to the file to be streamed" .BuildStreamPayload | comment }} -func {{ .BuildStreamPayload }}({{ if .Payload.Ref }}payload any, {{ end }}fpath string) (*{{ requestStructPkg .Method .ServicePkgName }}.{{ .Method.RequestStruct }}, error) { +// {{ printf "%s creates a streaming endpoint request payload from the method payload and the path to the file to be streamed" .BuildStreamPayloadDeclaration.Name | comment }} +func {{ .BuildStreamPayloadDeclaration.Name }}({{ if .Payload.Ref }}payload any, {{ end }}fpath string) (*{{ .ServicePkgName }}.{{ .Method.RequestStruct }}, error) { f, err := os.Open(fpath) if err != nil { return nil, err } - return &{{ requestStructPkg .Method .ServicePkgName }}.{{ .Method.RequestStruct }}{ + return &{{ .ServicePkgName }}.{{ .Method.RequestStruct }}{ {{- if .Payload.Ref }} Payload: payload.({{ .Payload.Ref }}), {{- end }} diff --git a/http/codegen/templates/cli_end.go.tpl b/http/codegen/templates/cli_end.go.tpl index 808f04b9fc..f2abdcf65f 100644 --- a/http/codegen/templates/cli_end.go.tpl +++ b/http/codegen/templates/cli_end.go.tpl @@ -1,4 +1,25 @@ -endpoint, payload, err := cli.ParseEndpoint( +{{- if hasAnyInputStreams .Services }} + switch flag.Arg(0) { + {{- range .Services }} + {{- if hasInputStreams . }} + case {{ printf "%q" (kebab .Service.PathName) }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + {{- if streamsInput .Method }} + case {{ printf "%q" (kebab .Method.Name) }}: + return errors.New({{ printf "%q" (printf "example client does not support streamed input for service %q method %q" .ServiceName .Method.Name) }}) + {{- end }} + {{- end }} + } + {{- end }} + {{- end }} + } +{{- end }} +{{- if hasRunnable .Services }} + endpoint, payload, err := {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- else }} + _, _, err := {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- end }} scheme, host, doer, @@ -16,7 +37,7 @@ endpoint, payload, err := cli.ParseEndpoint( {{- range .Services }} {{- range .Endpoints }} {{- if .MultipartRequestDecoder }} - {{ $.APIPkg }}.{{ .MultipartRequestEncoder.FuncName }}, + {{ $.APIPkg }}.{{ .MultipartRequestEncoder.FuncDeclaration.Name }}, {{- end }} {{- end }} {{- end }} @@ -27,7 +48,34 @@ endpoint, payload, err := cli.ParseEndpoint( {{- end }} ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil + +{{ if hasRunnable .Services }} + switch flag.Arg(0) { + {{- range .Services }} + {{- if hasRunnableService . }} + case {{ printf "%q" (kebab .Service.PathName) }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + {{- if not (streamsInput .Method) }} + case {{ printf "%q" (kebab .Method.Name) }}: + {{- if and (streamsOutput .Method) (not .HasMixedResults) }} + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.({{ .ServicePkgName }}.{{ .Method.ClientStream.Interface }}) + return writeStreamResults(ctx, stdout, stream.{{ .Method.ClientStream.RecvWithContextName }}) + {{- else }} + return writeEndpointResult(ctx, stdout, endpoint, payload) + {{- end }} + {{- end }} + {{- end }} + } + {{- end }} + {{- end }} + } + {{- end }} + panic({{ printf "%q" (printf "parsed %s command has no generated result writer" .Transport) }}) } diff --git a/http/codegen/templates/cli_start.go.tpl b/http/codegen/templates/cli_start.go.tpl index 6054afbdf6..8a3e7640fa 100644 --- a/http/codegen/templates/cli_start.go.tpl +++ b/http/codegen/templates/cli_start.go.tpl @@ -1,9 +1,9 @@ -func do{{ .FuncSuffix }}(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func do{{ .FuncSuffix }}(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer {{- range .Services }} {{- if .Service.ClientInterceptors }} - {{ .Service.VarName }}Interceptors {{ .Service.PkgName }}.ClientInterceptors + {{ .Service.VarName }}Interceptors {{ .Service.PkgName }}.{{ .Service.ClientInterceptorsDeclaration.Name }} {{- end }} {{- end }} ) diff --git a/http/codegen/templates/cli_usage.go.tpl b/http/codegen/templates/cli_usage.go.tpl index 49b881eb34..edc67cd66b 100644 --- a/http/codegen/templates/cli_usage.go.tpl +++ b/http/codegen/templates/cli_usage.go.tpl @@ -1,8 +1,4 @@ -func {{ .VarPrefix }}UsageCommands() []string { - return cli.UsageCommands() -} - func {{ .VarPrefix }}UsageExamples() string { - return cli.UsageExamples() + return {{ .CLIPkg }}.{{ .Parser.UsageExamples.Name }}() } diff --git a/http/codegen/templates/client_body_init.go.tpl b/http/codegen/templates/client_body_init.go.tpl index 1d598660bf..42f68d0067 100644 --- a/http/codegen/templates/client_body_init.go.tpl +++ b/http/codegen/templates/client_body_init.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}({{ range .ClientArgs }}{{ .VarName }} {{.TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{ range .ClientArgs }}{{ .VarName }} {{.TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{ .ClientCode }} return body } diff --git a/http/codegen/templates/client_endpoint_init.go.tpl b/http/codegen/templates/client_endpoint_init.go.tpl index e9cf0c79bf..ced2e48f4a 100644 --- a/http/codegen/templates/client_endpoint_init.go.tpl +++ b/http/codegen/templates/client_endpoint_init.go.tpl @@ -1,22 +1,22 @@ {{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) (not .Method.SkipRequestBodyEncodeDecode) (not .MultipartRequestEncoder) (not (isWebSocketEndpoint .)) (not (isSSEEndpoint .)) }} {{ printf "%s returns an endpoint that makes HTTP requests to the %s service %s server." .EndpointInit .ServiceName .Method.Name | comment }} -func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.VarName }} {{ .MultipartRequestEncoder.FuncName }}{{ end }}) goa.Endpoint { +func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.VarName }} {{ .MultipartRequestEncoder.FuncDeclaration.Name }}{{ end }}) goa.Endpoint { var ( - {{- if .RequestEncoder }} - encodeRequest = {{ .RequestEncoder }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.InitName }}({{ .MultipartRequestEncoder.VarName }}){{ else }}c.encoder{{ end }}) + {{- if .RequestEncoderDeclaration }} + encodeRequest = {{ .RequestEncoderDeclaration.Name }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.InitDeclaration.Name }}({{ .MultipartRequestEncoder.VarName }}){{ else }}c.encoder{{ end }}) {{- end }} - decodeResponse = {{ .ResponseDecoder }}(c.decoder, c.RestoreResponseBody) + decodeResponse = {{ .ResponseDecoderDeclaration.Name }}(c.decoder, c.RestoreResponseBody) ) {{- if $retry }} endpoint := func(ctx context.Context, v any) (any, error) { {{- else }} return func(ctx context.Context, v any) (any, error) { {{- end }} - req, err := c.{{ .RequestInit.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }}) + req, err := c.{{ .RequestInit.Declaration.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }}) if err != nil { return nil, err } - {{- if .RequestEncoder }} + {{- if .RequestEncoderDeclaration }} err = encodeRequest(req, v) if err != nil { return nil, err @@ -32,7 +32,7 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder return nil, goahttp.ErrRequestError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } if c.configurer.{{ .Method.VarName }}Fn != nil { - {{- if eq .ClientWebSocket.SendName "" }} + {{- if isServerStreamKind .ClientWebSocket.Kind }} var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) conn = c.configurer.{{ .Method.VarName }}Fn(conn, cancel) @@ -40,7 +40,7 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder conn = c.configurer.{{ .Method.VarName }}Fn(conn, nil) {{- end }} } - {{- if eq .ClientWebSocket.SendName "" }} + {{- if isServerStreamKind .ClientWebSocket.Kind }} go func() { <-ctx.Done() conn.WriteControl( @@ -51,7 +51,7 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder conn.Close() }() {{- end }} - stream := &{{ .ClientWebSocket.VarName }}{conn: conn} + stream := &{{ .ClientWebSocket.VarDeclaration.Name }}{conn: conn} {{- if .Method.ViewedResult }} {{- if not .Method.ViewedResult.ViewName }} view := resp.Header.Get("goa-view") @@ -77,11 +77,14 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder contentType := resp.Header.Get("Content-Type") if contentType != "" && !strings.HasPrefix(contentType, "text/event-stream") { - resp.Body.Close() - return nil, fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + contentTypeErr := fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + if err := resp.Body.Close(); err != nil { + return nil, errors.Join(contentTypeErr, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err)) + } + return nil, contentTypeErr } - return New{{ .Method.VarName }}Stream(resp, c.decoder), nil + return {{ .SSE.ClientInitDeclaration.Name }}(resp, c.decoder), nil {{- else }} resp, err := c.{{ .Method.VarName }}Doer.Do(req) if err != nil { @@ -90,10 +93,12 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder {{- if .Method.SkipResponseBodyEncodeDecode }} {{ if .Result.Ref }}res{{ else }}_{{ end }}, err {{ if .Result.Ref }}:{{ end }}= decodeResponse(resp) if err != nil { - resp.Body.Close() + if closeErr := resp.Body.Close(); closeErr != nil { + return nil, errors.Join(err, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", closeErr)) + } return nil, err } - return &{{ responseStructPkg .Method .ServicePkgName }}.{{ .Method.ResponseStruct }}{ {{ if .Result.Ref }}Result: res.({{ .Result.Ref }}), {{ end }}Body: resp.Body}, nil + return &{{ .ServicePkgName }}.{{ .Method.ResponseStruct }}{ {{ if .Result.Ref }}Result: res.({{ .Result.Ref }}), {{ end }}Body: resp.Body}, nil {{- else }} return decodeResponse(resp) {{- end }} diff --git a/http/codegen/templates/client_init.go.tpl b/http/codegen/templates/client_init.go.tpl index 9d76771638..104d2877ea 100644 --- a/http/codegen/templates/client_init.go.tpl +++ b/http/codegen/templates/client_init.go.tpl @@ -1,5 +1,5 @@ -{{ printf "New%s instantiates HTTP clients for all the %s service servers." .ClientStruct .Service.Name | comment }} -func New{{ .ClientStruct }}( +{{ printf "%s instantiates HTTP clients for all the %s service servers." .ClientInitDeclaration.Name .Service.Name | comment }} +func {{ .ClientInitDeclaration.Name }}( scheme string, host string, doer goahttp.Doer, @@ -10,13 +10,13 @@ func New{{ .ClientStruct }}( dialer goahttp.Dialer, cfn *ConnConfigurer, {{- end }} -) *{{ .ClientStruct }} { +) *{{ .ClientStructDeclaration.Name }} { {{- if hasWebSocket . }} if cfn == nil { cfn = &ConnConfigurer{} } {{- end }} - return &{{ .ClientStruct }}{ + return &{{ .ClientStructDeclaration.Name }}{ {{- range .Endpoints }} {{ .Method.VarName }}Doer: doer, {{- end }} diff --git a/http/codegen/templates/client_sse.go.tpl b/http/codegen/templates/client_sse.go.tpl index ecba21651f..810da40f18 100644 --- a/http/codegen/templates/client_sse.go.tpl +++ b/http/codegen/templates/client_sse.go.tpl @@ -1,5 +1,9 @@ -// {{ .Method.VarName }}ClientStream is the interface for reading Server-Sent Events. -type {{ .Method.VarName }}ClientStream interface { +{{/* +client_sse.go.tpl writes the HTTP client stream for one SSE endpoint. The plan +provides the exact data and retry types used to rebuild each service result. +*/ -}} +// {{ .SSE.ClientInterfaceDeclaration.Name }} is the interface for reading Server-Sent Events. +type {{ .SSE.ClientInterfaceDeclaration.Name }} interface { // {{ .Method.ClientStream.RecvName }} reads and returns the next event from the SSE stream. {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) // {{ .Method.ClientStream.RecvWithContextName }} reads and returns the next event from the SSE stream with context. @@ -9,39 +13,45 @@ type {{ .Method.VarName }}ClientStream interface { } type ( - // {{ .Method.VarName }}StreamImpl implements the {{ .Method.VarName }}ClientStream interface. - {{ .Method.VarName }}StreamImpl struct { + // {{ .SSE.ClientStructDeclaration.Name }} implements the {{ .SSE.ClientInterfaceDeclaration.Name }} interface. + {{ .SSE.ClientStructDeclaration.Name }} struct { resp *http.Response decoder func(*http.Response) goahttp.Decoder buffer []byte // Buffer for unprocessed data lock sync.Mutex closed bool + {{- if .SSE.VariableView }} + view string + {{- end }} } ) -// {{ .Method.VarName }}StreamImpl implements the {{ .Method.VarName }}ClientStream interface. -var _ {{ .Method.VarName }}ClientStream = (*{{ .Method.VarName }}StreamImpl)(nil) +// {{ .SSE.ClientStructDeclaration.Name }} implements the {{ .SSE.ClientInterfaceDeclaration.Name }} interface. +var _ {{ .SSE.ClientInterfaceDeclaration.Name }} = (*{{ .SSE.ClientStructDeclaration.Name }})(nil) -// {{ .Method.VarName }}StreamImpl implements the service client stream +// {{ .SSE.ClientStructDeclaration.Name }} implements the service client stream // interface so the generated endpoint client can return it directly. -var _ {{ .ServicePkgName }}.{{ .Method.ClientStream.Interface }} = (*{{ .Method.VarName }}StreamImpl)(nil) +var _ {{ .ServicePkgName }}.{{ .Method.ClientStream.Interface }} = (*{{ .SSE.ClientStructDeclaration.Name }})(nil) -// New{{ .Method.VarName }}Stream creates a new {{ .Method.VarName }}ClientStream. -func New{{ .Method.VarName }}Stream(resp *http.Response, decoder func(*http.Response) goahttp.Decoder) {{ .Method.VarName }}ClientStream { - return &{{ .Method.VarName }}StreamImpl{ +// {{ .SSE.ClientInitDeclaration.Name }} creates a new {{ .SSE.ClientInterfaceDeclaration.Name }}. +func {{ .SSE.ClientInitDeclaration.Name }}(resp *http.Response, decoder func(*http.Response) goahttp.Decoder) {{ .SSE.ClientInterfaceDeclaration.Name }} { + return &{{ .SSE.ClientStructDeclaration.Name }}{ resp: resp, decoder: decoder, buffer: make([]byte, 0, 4096), // Pre-allocate buffer + {{- if .SSE.VariableView }} + view: resp.Header.Get("goa-view"), + {{- end }} } } // {{ .Method.ClientStream.RecvName }} reads and returns the next event from the SSE stream. -func (s *{{ .Method.VarName }}StreamImpl) {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) { return s.{{ .Method.ClientStream.RecvWithContextName }}(context.Background()) } // {{ .Method.ClientStream.RecvWithContextName }} reads and returns the next event from the SSE stream, respecting context cancellation. -func (s *{{ .Method.VarName }}StreamImpl) {{ .Method.ClientStream.RecvWithContextName }}(ctx context.Context) (event {{ .SSE.EventTypeRef }}, err error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvWithContextName }}(ctx context.Context) (event {{ .SSE.EventTypeRef }}, err error) { var byts []byte byts, err = s.readEvent(ctx) if err != nil { @@ -61,7 +71,7 @@ func (s *{{ .Method.VarName }}StreamImpl) {{ .Method.ClientStream.RecvWithContex // the HTTP response body until it either finds an event boundary, reaches EOF, // or encounters an error. Any data after the event boundary is saved in the // buffer for the next call. -func (s *{{ .Method.VarName }}StreamImpl) readEvent(ctx context.Context) ([]byte, error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) readEvent(ctx context.Context) ([]byte, error) { const bufSize = 4096 // 4KB buffer size // Check for event in existing buffer @@ -139,7 +149,7 @@ func (s *{{ .Method.VarName }}StreamImpl) readEvent(ctx context.Context) ([]byte // contents if no complete event is found), and a boolean indicating whether a // complete event was found. If a complete event is found, any remaining data // after the event is kept in the buffer for the next call. -func (s *{{ .Method.VarName }}StreamImpl) checkBuffer() ([]byte, bool) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) checkBuffer() ([]byte, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -179,7 +189,7 @@ func (s *{{ .Method.VarName }}StreamImpl) checkBuffer() ([]byte, bool) { } // Close closes the SSE stream and releases any associated resources. -func (s *{{ .Method.VarName }}StreamImpl) Close() error { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) Close() error { s.lock.Lock() defer s.lock.Unlock() if s.closed { @@ -190,47 +200,117 @@ func (s *{{ .Method.VarName }}StreamImpl) Close() error { } // processEvent processes a raw SSE event into the expected type -func (s *{{ .Method.VarName }}StreamImpl) processEvent(eventData []byte) (event {{ .SSE.EventTypeRef }}, err error) { - {{- if .SSE.EventIsStruct }} - event = new({{ deref .SSE.EventTypeRef }}) - {{- end }} +func (s *{{ .SSE.ClientStructDeclaration.Name }}) processEvent(eventData []byte) (event {{ .SSE.EventTypeRef }}, err error) { + {{- if and .SSE.EventIsStruct (not .HasMixedResults) }} + event = new({{ .SSE.EventTypeName }}) + {{- end }} + {{- if .HasMixedResults }} + {{- with .SSE.Response.ClientBody }} + var body {{ if .Declaration }}{{ .Declaration.Name }}{{ else }}{{ .VarName }}{{ end }} + {{- end }} + {{- end }} var dataLines []string for _, line := range bytes.Split(eventData, []byte("\n")) { if len(line) == 0 { continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } {{- if .SSE.IDField }} if bytes.HasPrefix(line, []byte("id:")) { - event.{{ .SSE.IDField }} = s.trimHeader(len("id:"), line) + {{- if and $.HasMixedResults $.SSE.ClientIDPointer }} + idContent := s.trimHeader(line[len("id:"):]) + body.{{ .SSE.IDField }} = &idContent + {{- else }} + {{ if $.HasMixedResults }}body{{ else }}event{{ end }}.{{ .SSE.IDField }} = s.trimHeader(line[len("id:"):]) + {{- end }} continue } {{- end }} {{- if .SSE.EventField }} if bytes.HasPrefix(line, []byte("event:")) { - event.{{ .SSE.EventField }} = s.trimHeader(len("event:"), line) + {{- if and $.HasMixedResults $.SSE.ClientEventPointer }} + eventContent := s.trimHeader(line[len("event:"):]) + body.{{ .SSE.EventField }} = &eventContent + {{- else }} + {{ if $.HasMixedResults }}body{{ else }}event{{ end }}.{{ .SSE.EventField }} = s.trimHeader(line[len("event:"):]) + {{- end }} continue } {{- end }} {{- if .SSE.RetryField }} if bytes.HasPrefix(line, []byte("retry:")) { - // Note: retry value parsing depends on the field type; client currently expects integer-like types. - // We deliberately leave conversion to a future enhancement that includes the field type reference. - // For now this branch is kept for completeness; services using RetryField should be handled server-side. + retryContent := s.trimHeader(line[len("retry:"):]) + {{- if $.HasMixedResults }} + {{ template "partial_sse_parse" dict "Target" (printf "body.%s" .SSE.RetryField) "Source" "retryContent" "Encoding" .SSE.Retry "Nullable" false "TargetPointer" .SSE.Retry.ClientPointer }} + {{- else }} + {{ template "partial_sse_parse" dict "Target" (printf "event.%s" .SSE.RetryField) "Source" "retryContent" "Encoding" .SSE.Retry "Nullable" false "TargetPointer" .SSE.Retry.Pointer }} + {{- end }} continue } {{- end }} } + {{- if .Method.ViewedResult }} + {{- template "viewed_sse_response_elements" . }} + {{- if .SSE.VariableView }} + view := s.view + switch view { + {{- range .SSE.Response.ViewedRepresentations }} + case {{ printf "%q" .View }}: + {{- template "viewed_sse_client_result" dict "Endpoint" $ "Representation" . }} + {{- end }} + default: + return event, goahttp.ErrValidationError("{{ .ServiceName }}", "{{ .Method.Name }}", goa.InvalidEnumValueError("view", view, []any{ {{ range .Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} })) + } + {{- else }} + view := {{ printf "%q" .Method.ViewedResult.ViewName }} + {{- range .SSE.Response.ViewedRepresentations }} + {{- template "viewed_sse_client_result" dict "Endpoint" $ "Representation" . }} + {{- end }} + {{- end }} + {{- else if .HasMixedResults }} + {{- with .SSE.Response.ClientBody }} if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") - {{- if .SSE.DataField }} - {{ template "partial_sse_parse" dict "Target" (printf "event.%s" .SSE.DataField) "TypeRef" .SSE.DataFieldTypeRef }} + {{- if $.SSE.DataField }} + {{ template "partial_sse_parse" dict "Target" (printf "body.%s" $.SSE.DataField) "Source" "dataContent" "Encoding" $.SSE.Data "Nullable" $.SSE.Data.Pointer "TargetPointer" $.SSE.Data.ClientPointer }} + {{- else if ssePrimitive $.SSE.Data }} + {{ template "partial_sse_parse" dict "Target" "body" "Source" "dataContent" "Encoding" $.SSE.Data "Nullable" $.SSE.Data.Pointer "TargetPointer" $.SSE.Data.Pointer }} {{- else }} - {{- if .SSE.EventIsStruct }} - // Decode JSON into the struct pointer directly + respBody := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), + } + if err = s.decoder(respBody).Decode(&body); err != nil { + return event, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + } + {{- if and .ValidatorDeclaration .ValidationTarget }} + err = {{ .ValidatorDeclaration.Name }}({{ .ValidationTarget }}) + if err != nil { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- else if .ValidateRef }} + {{ .ValidateRef }} + if err != nil { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + {{ $.SSE.ClientEventCode }} + return result, nil + {{- else }} + return event, nil + {{- end }} + {{- else }} + if len(dataLines) > 0 { + dataContent := strings.Join(dataLines, "\n") + {{- if .SSE.DataField }} + {{ template "partial_sse_parse" dict "Target" (printf "event.%s" .SSE.DataField) "Source" "dataContent" "Encoding" .SSE.Data "Nullable" .SSE.Data.Pointer "TargetPointer" .SSE.Data.Pointer }} + {{- else if .SSE.EventIsStruct }} + // Decode the event data into the result value returned by Recv. respBody := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), @@ -240,19 +320,163 @@ func (s *{{ .Method.VarName }}StreamImpl) processEvent(eventData []byte) (event return } {{- else }} - {{ template "partial_sse_parse" dict "Target" "event" "TypeRef" .SSE.EventTypeRef }} - {{- end }} + {{ template "partial_sse_parse" dict "Target" "event" "Source" "dataContent" "Encoding" .SSE.Data "Nullable" .SSE.Data.Pointer "TargetPointer" .SSE.Data.Pointer }} {{- end }} } - return + {{- end }} + {{- if not .HasMixedResults }} + return + {{- end }} } -// trimHeader removes the header prefix and optional leading space -func (s *{{ .Method.VarName }}StreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +{{- define "viewed_sse_client_result" }} + {{- $endpoint := .Endpoint }} + {{- with .Representation }} + {{- if .ClientBody }} + var body {{ if .ClientBody.Declaration }}{{ .ClientBody.Declaration.Name }}{{ else }}{{ .ClientBody.VarName }}{{ end }} + {{- if $endpoint.SSE.IDField }} + body.{{ $endpoint.SSE.IDField }} = event.{{ $endpoint.SSE.IDField }} + {{- end }} + {{- if $endpoint.SSE.EventField }} + body.{{ $endpoint.SSE.EventField }} = event.{{ $endpoint.SSE.EventField }} + {{- end }} + if len(dataLines) > 0 { + dataContent := strings.Join(dataLines, "\n") + {{- if ssePrimitive $endpoint.SSE.Data }} + {{- if $endpoint.SSE.DataField }} + {{ template "partial_sse_parse" dict "Target" (printf "body.%s" $endpoint.SSE.DataField) "Source" "dataContent" "Encoding" $endpoint.SSE.Data "Nullable" $endpoint.SSE.Data.Pointer "TargetPointer" .ClientDataPointer }} + {{- else }} + {{ template "partial_sse_parse" dict "Target" "body" "Source" "dataContent" "Encoding" $endpoint.SSE.Data "Nullable" $endpoint.SSE.Data.Pointer "TargetPointer" .ClientDataPointer }} + {{- end }} + {{- else }} + respBody := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), + } + {{- if $endpoint.SSE.DataField }} + if err = s.decoder(respBody).Decode(&body.{{ $endpoint.SSE.DataField }}); err != nil { + {{- else }} + if err = s.decoder(respBody).Decode(&body); err != nil { + {{- end }} + return event, goahttp.ErrDecodingError("{{ $endpoint.ServiceName }}", "{{ $endpoint.Method.Name }}", err) + } + {{- end }} + } + {{- end }} + projected := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }}, {{ end }}) + viewed := {{ if not $endpoint.Method.ViewedResult.IsCollection }}&{{ end }}{{ $endpoint.Method.ViewedResult.ViewsPkg }}.{{ $endpoint.Method.ViewedResult.VarName }}{Projected: projected, View: view} + if err = {{ $endpoint.Method.ViewedResult.ViewsPkg }}.{{ $endpoint.Method.ViewedResult.Validate.Declaration.Name }}(viewed); err != nil { + return event, goahttp.ErrValidationError("{{ $endpoint.ServiceName }}", "{{ $endpoint.Method.Name }}", err) + } + result := {{ $endpoint.ServicePkgName }}.{{ $endpoint.Method.ViewedResult.ResultInit.Declaration.Name }}(viewed) + {{- if $endpoint.SSE.IDField }} + result.{{ $endpoint.SSE.IDField }} = event.{{ $endpoint.SSE.IDField }} + {{- end }} + {{- if $endpoint.SSE.EventField }} + result.{{ $endpoint.SSE.EventField }} = event.{{ $endpoint.SSE.EventField }} + {{- end }} + return result, nil + {{- end }} +{{- end }} + +{{- define "viewed_sse_response_elements" }} + {{- with .SSE.Response }} + {{- if .Headers }} + var ( + {{- range .Headers }} + {{ .VarName }} {{ .TypeRef }} + {{- end }} + ) + {{- range .Headers }} + {{- if (or (eq .Type.Name "string") (eq .Type.Name "any")) }} + {{ .VarName }}Raw := s.resp.Header.Get("{{ .CanonicalName }}") + {{- if .Required }} + if {{ .VarName }}Raw == "" { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "header")) + } + {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{- else }} + if {{ .VarName }}Raw != "" { + {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + } + {{- end }} + {{- else if .StringSlice }} + {{ .VarName }} = s.resp.Header["{{ .CanonicalName }}"] + {{- if .Required }} + if {{ .VarName }} == nil { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "header")) + } + {{- end }} + {{- else if .Slice }} + {{ .VarName }}Raw := s.resp.Header["{{ .CanonicalName }}"] + {{- if .Required }} + if {{ .VarName }}Raw == nil { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "header")) + } + {{- end }} + if {{ .VarName }}Raw != nil { + {{- template "partial_element_slice_conversion" . }} + } + {{- else }} + {{ .VarName }}Raw := s.resp.Header.Get("{{ .CanonicalName }}") + {{- if .Required }} + if {{ .VarName }}Raw == "" { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "header")) + } + {{- end }} + if {{ .VarName }}Raw != "" { + {{- template "partial_query_type_conversion" . }} + } + {{- end }} + {{- if .Validate }} + {{ .Validate }} + if err != nil { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + {{- end }} + {{- end }} + {{- if .Cookies }} + var ( + {{- range .Cookies }} + {{ .VarName }} {{ .TypeRef }} + {{ .VarName }}Raw string + {{- end }} + ) + for _, cookie := range s.resp.Cookies() { + switch cookie.Name { + {{- range .Cookies }} + case {{ printf "%q" .HTTPName }}: + {{ .VarName }}Raw = cookie.Value + {{- end }} + } + } + {{- range .Cookies }} + {{- if .Required }} + if {{ .VarName }}Raw == "" { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "cookie")) + } + {{- end }} + if {{ .VarName }}Raw != "" { + {{- if (or (eq .Type.Name "string") (eq .Type.Name "any")) }} + {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{- else }} + {{- template "partial_query_type_conversion" . }} + {{- end }} + } + {{- if .Validate }} + {{ .Validate }} + if err != nil { + return event, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} + +// trimHeader removes the optional space after an SSE field name. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/templates/client_struct.go.tpl b/http/codegen/templates/client_struct.go.tpl index f7b286636e..01c41eab00 100644 --- a/http/codegen/templates/client_struct.go.tpl +++ b/http/codegen/templates/client_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s lists the %s service endpoint HTTP clients." .ClientStruct .Service.Name | comment }} -type {{ .ClientStruct }} struct { +{{ printf "%s lists the %s service endpoint HTTP clients." .ClientStructDeclaration.Name .Service.Name | comment }} +type {{ .ClientStructDeclaration.Name }} struct { {{- range .Endpoints }} {{ printf "%s Doer is the HTTP client used to make requests to the %s endpoint." .Method.VarName .Method.Name | comment }} {{ .Method.VarName }}Doer goahttp.Doer diff --git a/http/codegen/templates/client_type_init.go.tpl b/http/codegen/templates/client_type_init.go.tpl index 7324604835..7ac4147b66 100644 --- a/http/codegen/templates/client_type_init.go.tpl +++ b/http/codegen/templates/client_type_init.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}({{- range .ClientArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{- range .ClientArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{- if .ClientCode }} {{ .ClientCode }} {{- if .ReturnTypeAttribute }} diff --git a/http/codegen/templates/dummy_multipart_request_decoder.go.tpl b/http/codegen/templates/dummy_multipart_request_decoder.go.tpl index 132d6ba52c..4ab092f1e7 100644 --- a/http/codegen/templates/dummy_multipart_request_decoder.go.tpl +++ b/http/codegen/templates/dummy_multipart_request_decoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s implements the multipart decoder for service %q endpoint %q. The decoder must populate the argument p after encoding." .FuncName .ServiceName .MethodName | comment }} -func {{ .FuncName }}(mr *multipart.Reader, p *{{ .Payload.Ref }}) error { +{{ printf "%s reads the multipart request body for service %q endpoint %q into body." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .FuncDeclaration.Name }}(mr *multipart.Reader, body *{{ .BodyType }}) error { // Add multipart request decoder logic here return nil } diff --git a/http/codegen/templates/dummy_multipart_request_encoder.go.tpl b/http/codegen/templates/dummy_multipart_request_encoder.go.tpl index ec588acd12..c27201f641 100644 --- a/http/codegen/templates/dummy_multipart_request_encoder.go.tpl +++ b/http/codegen/templates/dummy_multipart_request_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s implements the multipart encoder for service %q endpoint %q." .FuncName .ServiceName .MethodName | comment }} -func {{ .FuncName }}(mw *multipart.Writer, p {{ .Payload.Ref }}) error { +{{ printf "%s implements the multipart encoder for service %q endpoint %q." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .FuncDeclaration.Name }}(mw *multipart.Writer, p {{ .Payload.Ref }}) error { // Add multipart request encoder logic here return nil } diff --git a/http/codegen/templates/error_encoder.go.tpl b/http/codegen/templates/error_encoder.go.tpl index 31f48dbe6b..067e6fc0f6 100644 --- a/http/codegen/templates/error_encoder.go.tpl +++ b/http/codegen/templates/error_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s returns an encoder for errors returned by the %s %s endpoint." .ErrorEncoder .Method.Name .ServiceName | comment }} -func {{ .ErrorEncoder }}(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { +{{ printf "%s returns an encoder for errors returned by the %s %s endpoint." .ErrorEncoderDeclaration.Name .Method.Name .ServiceName | comment }} +func {{ .ErrorEncoderDeclaration.Name }}(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { encodeError := goahttp.ErrorEncoder(encoder, formatter) return func(ctx context.Context, w http.ResponseWriter, v error) error { var en goa.GoaErrorNamer diff --git a/http/codegen/templates/file_server.go.tpl b/http/codegen/templates/file_server.go.tpl index f3b2d1bf06..fde37a6570 100644 --- a/http/codegen/templates/file_server.go.tpl +++ b/http/codegen/templates/file_server.go.tpl @@ -1,5 +1,8 @@ -{{ printf "%s configures the mux to serve GET request made to %q." .MountHandler (join .RequestPaths ", ") | comment }} -func {{ .MountHandler }}(mux goahttp.Muxer, h http.Handler) { +{{ printf "%s configures the mux to serve GET request made to %q." .MountHandlerDeclaration.Name (join .RequestPaths ", ") | comment }} +func {{ .MountHandlerDeclaration.Name }}(mux goahttp.Muxer, h http.Handler) { + {{- if .ServerHandlerWrappers }} + h = {{ range .ServerHandlerWrappers }}{{ .Name }}({{ end }}h{{ range .ServerHandlerWrappers }}){{ end }} + {{- end }} {{- if .IsDir }} {{- range .RequestPaths }} mux.Handle("GET", "{{ . }}{{if ne . "/"}}/{{end}}", h.ServeHTTP) diff --git a/http/codegen/templates/mount_point_struct.go.tpl b/http/codegen/templates/mount_point_struct.go.tpl index b4739ba2a7..d928733168 100644 --- a/http/codegen/templates/mount_point_struct.go.tpl +++ b/http/codegen/templates/mount_point_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s holds information about the mounted endpoints." .MountPointStruct | comment }} -type {{ .MountPointStruct }} struct { +{{ printf "%s holds information about the mounted endpoints." .MountPointStructDeclaration.Name | comment }} +type {{ .MountPointStructDeclaration.Name }} struct { {{ printf "Method is the name of the service method served by the mounted HTTP handler." | comment }} Method string {{ printf "Verb is the HTTP method used to match requests to the mounted handler." | comment }} diff --git a/http/codegen/templates/multipart_request_decoder.go.tpl b/http/codegen/templates/multipart_request_decoder.go.tpl index dab9d3ef8f..b7e3d47e5c 100644 --- a/http/codegen/templates/multipart_request_decoder.go.tpl +++ b/http/codegen/templates/multipart_request_decoder.go.tpl @@ -1,28 +1,15 @@ -{{ printf "%s returns a decoder to decode the multipart request for the %q service %q endpoint." .InitName .ServiceName .MethodName | comment }} -func {{ .InitName }}(mux goahttp.Muxer, {{ .VarName }} {{ .FuncName }}) func(r *http.Request) goahttp.Decoder { +{{ printf "%s returns a decoder to decode the multipart request for the %q service %q endpoint." .InitDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .InitDeclaration.Name }}(_ goahttp.Muxer, {{ .VarName }} {{ .FuncDeclaration.Name }}) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(*{{ .Payload.Ref }}) - if err := {{ .VarName }}(mr, p); err != nil { + body := v.(*{{ if .Payload.Request.ServerBody.Declaration }}{{ .Payload.Request.ServerBody.Declaration.Name }}{{ else }}{{ .Payload.Request.ServerBody.VarName }}{{ end }}) + if err := {{ .VarName }}(mr, body); err != nil { return err } - {{- template "partial_request_elements" .Payload.Request }} - {{- if .Payload.Request.MustValidate }} - if err != nil { - return err - } - {{- end }} - {{- if .Payload.Request.PayloadInit }} - {{- range .Payload.Request.PayloadInit.ServerArgs }} - {{- if .FieldName }} - (*p).{{ .FieldName }} = {{ if and (not .Pointer) .FieldPointer }}&{{ end }}{{ .VarName }} - {{- end }} - {{- end }} - {{- end }} return nil }) } diff --git a/http/codegen/templates/multipart_request_decoder_type.go.tpl b/http/codegen/templates/multipart_request_decoder_type.go.tpl index 7dda99adff..db7211a4f6 100644 --- a/http/codegen/templates/multipart_request_decoder_type.go.tpl +++ b/http/codegen/templates/multipart_request_decoder_type.go.tpl @@ -1,2 +1,2 @@ -{{ printf "%s is the type to decode multipart request for the %q service %q endpoint." .FuncName .ServiceName .MethodName | comment }} -type {{ .FuncName }} func(*multipart.Reader, *{{ .Payload.Ref }}) error +{{ printf "%s is the type to decode multipart request for the %q service %q endpoint." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +type {{ .FuncDeclaration.Name }} func(*multipart.Reader, *{{ if .Payload.Request.ServerBody.Declaration }}{{ .Payload.Request.ServerBody.Declaration.Name }}{{ else }}{{ .Payload.Request.ServerBody.VarName }}{{ end }}) error diff --git a/http/codegen/templates/multipart_request_encoder.go.tpl b/http/codegen/templates/multipart_request_encoder.go.tpl index 9fd9625b98..d75fea859a 100644 --- a/http/codegen/templates/multipart_request_encoder.go.tpl +++ b/http/codegen/templates/multipart_request_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s returns an encoder to encode the multipart request for the %q service %q endpoint." .InitName .ServiceName .MethodName | comment }} -func {{ .InitName }}(encoderFn {{ .FuncName }}) func(r *http.Request) goahttp.Encoder { +{{ printf "%s returns an encoder to encode the multipart request for the %q service %q endpoint." .InitDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .InitDeclaration.Name }}(encoderFn {{ .FuncDeclaration.Name }}) func(r *http.Request) goahttp.Encoder { return func(r *http.Request) goahttp.Encoder { body := &bytes.Buffer{} mw := multipart.NewWriter(body) diff --git a/http/codegen/templates/multipart_request_encoder_type.go.tpl b/http/codegen/templates/multipart_request_encoder_type.go.tpl index c6633349fa..1e0c11ec34 100644 --- a/http/codegen/templates/multipart_request_encoder_type.go.tpl +++ b/http/codegen/templates/multipart_request_encoder_type.go.tpl @@ -1,2 +1,2 @@ -{{ printf "%s is the type to encode multipart request for the %q service %q endpoint." .FuncName .ServiceName .MethodName | comment }} -type {{ .FuncName }} func(*multipart.Writer, {{ .Payload.Ref }}) error +{{ printf "%s is the type to encode multipart request for the %q service %q endpoint." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +type {{ .FuncDeclaration.Name }} func(*multipart.Writer, {{ .Payload.Ref }}) error diff --git a/http/codegen/templates/parse_endpoint.go.tpl b/http/codegen/templates/parse_endpoint.go.tpl index 64f098eead..fdc864fc3a 100644 --- a/http/codegen/templates/parse_endpoint.go.tpl +++ b/http/codegen/templates/parse_endpoint.go.tpl @@ -1,59 +1,59 @@ // ParseEndpoint returns the endpoint and payload as specified on the command // line. -func ParseEndpoint( - scheme, host string, - doer goahttp.Doer, - enc func(*http.Request) goahttp.Encoder, - dec func(*http.Response) goahttp.Decoder, - restore bool, +func {{ .Declaration.Name }}( + {{ .Variables.Scheme }}, {{ .Variables.Host }} string, + {{ .Variables.Doer }} goahttp.Doer, + {{ .Variables.Encoder }} func(*http.Request) goahttp.Encoder, + {{ .Variables.Decoder }} func(*http.Response) goahttp.Decoder, + {{ .Variables.Restore }} bool, {{- if streamingCmdExists .Commands }} - dialer goahttp.Dialer, + {{ .Variables.Dialer }} goahttp.Dialer, {{- range .Commands }} {{- if .NeedDialer }} - {{ if .JSONRPC }}{{ .VarName }}ConfigFn goahttp.ConnConfigureFunc,{{ else }}{{ .VarName }}Configurer *{{ .PkgName }}.ConnConfigurer,{{ end }} + {{ if .JSONRPC }}{{ .ConfigurerLocal.VarName }} goahttp.ConnConfigureFunc,{{ else }}{{ .ConfigurerLocal.VarName }} *{{ .PkgName }}.{{ .Configurer.Name }},{{ end }} {{- end }} {{- end }} {{- end }} {{- range $i, $c := .Commands }} {{- range .Subcommands }} {{- if .MultipartVarName }} - {{ .MultipartVarName }} {{ $c.PkgName }}.{{ .MultipartFuncName }}, + {{ .MultipartLocal.VarName }} {{ $c.PkgName }}.{{ .MultipartFuncDeclaration.Name }}, {{- end }} {{- end }} {{- if .Interceptors }} - {{ .Interceptors.VarName }} {{ .Interceptors.PkgName }}.ClientInterceptors, + {{ .Interceptors.ParserVar }} {{ .Interceptors.PkgName }}.{{ .Interceptors.ClientInterceptorsDeclaration.Name }}, {{- end }} {{- end }} ) (goa.Endpoint, any, error) { {{ .FlagsCode }} var ( - data any - endpoint goa.Endpoint - err error + {{ .Variables.Data }} any + {{ .Variables.Endpoint }} goa.Endpoint + {{ .Variables.Error }} error ) { - switch svcn { + switch {{ .Variables.ServiceName }} { {{- range .Commands }} case "{{ .Name }}": - c := {{ .PkgName }}.NewClient(scheme, host, doer, enc, dec, restore{{ if .NeedDialer }}, dialer, {{ if .JSONRPC }}{{ .VarName }}ConfigFn{{ else }}{{ .VarName }}Configurer{{ end }}{{ end }}) - switch epn { + {{ $.Variables.Client }} := {{ .PkgName }}.{{ .ClientInit.Name }}({{ $.Variables.Scheme }}, {{ $.Variables.Host }}, {{ $.Variables.Doer }}, {{ $.Variables.Encoder }}, {{ $.Variables.Decoder }}, {{ $.Variables.Restore }}{{ if .NeedDialer }}, {{ $.Variables.Dialer }}, {{ .ConfigurerLocal.VarName }}{{ end }}) + switch {{ $.Variables.MethodName }} { {{- $pkgName := .PkgName }} {{- range .Subcommands }} case "{{ .Name }}": - endpoint = c.{{ .MethodVarName }}({{ if .MultipartVarName }}{{ .MultipartVarName }}{{ end }}) + {{ $.Variables.Endpoint }} = {{ $.Variables.Client }}.{{ .MethodVarName }}({{ if .MultipartLocal }}{{ .MultipartLocal.VarName }}{{ end }}) {{- if .Interceptors }} - endpoint = {{ .Interceptors.PkgName }}.Wrap{{ .MethodVarName }}ClientEndpoint(endpoint, {{ .Interceptors.VarName }}) + {{ $.Variables.Endpoint }} = {{ .Interceptors.PkgName }}.{{ .Interceptors.ClientEndpointWrapperDeclaration.Name }}({{ $.Variables.Endpoint }}, {{ .Interceptors.ParserVar }}) {{- end }} {{- if .BuildFunction }} - data, err = {{ $pkgName }}.{{ .BuildFunction.Name }}({{ range .BuildFunction.ActualParams }}*{{ . }}Flag, {{ end }}) + {{ $.Variables.Data }}, {{ $.Variables.Error }} = {{ $pkgName }}.{{ .BuildFunction.Name }}({{ range .ActualPointerVars }}*{{ . }}, {{ end }}) {{- else if .Conversion }} {{ .Conversion }} {{- end }} {{- if .StreamFlag }} {{- if .BuildFunction }} - if err == nil { + if {{ $.Variables.Error }} == nil { {{- end }} - data, err = {{ $pkgName }}.{{ .BuildStreamPayload }}({{ if or .BuildFunction .Conversion }}data, {{ end }}*{{ .StreamFlag.FullName }}Flag) + {{ $.Variables.Data }}, {{ $.Variables.Error }} = {{ $pkgName }}.{{ .BuildStreamPayloadDeclaration.Name }}({{ if or .BuildFunction .Conversion }}{{ $.Variables.Data }}, {{ end }}*{{ .StreamPointerVar }}) {{- if .BuildFunction }} } {{- end }} @@ -63,9 +63,9 @@ func ParseEndpoint( {{- end }} } } - if err != nil { - return nil, nil, err + if {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} } - return endpoint, data, nil + return {{ .Variables.Endpoint }}, {{ .Variables.Data }}, nil } diff --git a/http/codegen/templates/partial/client_type_conversion.go.tpl b/http/codegen/templates/partial/client_type_conversion.go.tpl index d4382c027f..7e1fc34b5c 100644 --- a/http/codegen/templates/partial/client_type_conversion.go.tpl +++ b/http/codegen/templates/partial/client_type_conversion.go.tpl @@ -1,27 +1 @@ - {{- if eq .Type.Name "boolean" -}} - {{ .VarName }} := strconv.FormatBool({{ if .IsAliased }}bool({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}) - {{- else if eq .Type.Name "int" -}} - {{ .VarName }} := strconv.Itoa({{ if .IsAliased }}int({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}) - {{- else if eq .Type.Name "int32" -}} - {{ .VarName }} := strconv.FormatInt(int64({{ .Target }}), 10) - {{- else if eq .Type.Name "int64" -}} - {{ .VarName }} := strconv.FormatInt({{ if .IsAliased }}int64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 10) - {{- else if eq .Type.Name "uint" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) - {{- else if eq .Type.Name "uint32" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) - {{- else if eq .Type.Name "uint64" -}} - {{ .VarName }} := strconv.FormatUint({{ if .IsAliased }}uint64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 10) - {{- else if eq .Type.Name "float32" -}} - {{ .VarName }} := strconv.FormatFloat(float64({{ .Target }}), 'f', -1, 32) - {{- else if eq .Type.Name "float64" -}} - {{ .VarName }} := strconv.FormatFloat({{ if .IsAliased }}float64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 'f', -1, 64) - {{- else if eq .Type.Name "string" -}} - {{ .VarName }} := {{ if .IsAliased }}string({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }} - {{- else if eq .Type.Name "bytes" -}} - {{ .VarName }} := string({{ .Target }}) - {{- else if eq .Type.Name "any" -}} - {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) - {{- else }} - // unsupported type {{ .Type.Name }} for field {{ .FieldName }} - {{- end }} +{{- .VarName }} := {{ template "partial_client_type_expression" . }} diff --git a/http/codegen/templates/partial/client_type_expression.go.tpl b/http/codegen/templates/partial/client_type_expression.go.tpl new file mode 100644 index 0000000000..be77be13f0 --- /dev/null +++ b/http/codegen/templates/partial/client_type_expression.go.tpl @@ -0,0 +1,23 @@ +{{- if eq .Type.Name "boolean" -}} +strconv.FormatBool({{ if .IsAliased }}bool({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}) +{{- else if eq .Type.Name "int" -}} +strconv.Itoa({{ if .IsAliased }}int({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}) +{{- else if eq .Type.Name "int32" -}} +strconv.FormatInt(int64({{ .Target }}), 10) +{{- else if eq .Type.Name "int64" -}} +strconv.FormatInt({{ if .IsAliased }}int64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 10) +{{- else if eq .Type.Name "uint" "uint32" -}} +strconv.FormatUint(uint64({{ .Target }}), 10) +{{- else if eq .Type.Name "uint64" -}} +strconv.FormatUint({{ if .IsAliased }}uint64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 10) +{{- else if eq .Type.Name "float32" -}} +strconv.FormatFloat(float64({{ .Target }}), 'g', -1, 32) +{{- else if eq .Type.Name "float64" -}} +strconv.FormatFloat({{ if .IsAliased }}float64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 'g', -1, 64) +{{- else if eq .Type.Name "string" -}} +{{ if .IsAliased }}string({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }} +{{- else if eq .Type.Name "bytes" -}} +string({{ .Target }}) +{{- else if eq .Type.Name "any" -}} +fmt.Sprintf("%v", {{ .Target }}) +{{- end }} diff --git a/http/codegen/templates/partial/request_elements.go.tpl b/http/codegen/templates/partial/request_elements.go.tpl index 6849830237..d9d948b1e9 100644 --- a/http/codegen/templates/partial/request_elements.go.tpl +++ b/http/codegen/templates/partial/request_elements.go.tpl @@ -14,7 +14,7 @@ {{- range .Cookies }} {{ .VarName }} {{ .TypeRef }} {{- end }} - {{- if and .MustValidate (or (not .ServerBody) .Multipart) }} + {{- if and .MustValidate (not .ServerBody) }} err error {{- end }} {{- if .Cookies }} diff --git a/http/codegen/templates/partial/response.go.tpl b/http/codegen/templates/partial/response.go.tpl index 895c35517c..68e589ce1c 100644 --- a/http/codegen/templates/partial/response.go.tpl +++ b/http/codegen/templates/partial/response.go.tpl @@ -7,7 +7,7 @@ {{- range $.ViewedResult.Views }} case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: {{- $vsb := (viewedServerBody $.ServerBody .Name) }} - body = {{ $vsb.Init.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body = {{ $vsb.Init.Declaration.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- end }} } {{- else if (index .ServerBody 0).Init }} @@ -17,7 +17,7 @@ body = formatter(ctx, {{ (index (index .ServerBody 0).Init.ServerArgs 0).Ref }}) } else { {{- end }} - body {{ if not .ErrorHeader}}:{{ end }}= {{ (index .ServerBody 0).Init.Name }}({{ range (index .ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body {{ if not .ErrorHeader}}:{{ end }}= {{ (index .ServerBody 0).Init.Declaration.Name }}({{ range (index .ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- if .ErrorHeader }} } {{- end }} diff --git a/http/codegen/templates/partial/single_response.go.tpl b/http/codegen/templates/partial/single_response.go.tpl index 764446207d..85eafbcfe7 100644 --- a/http/codegen/templates/partial/single_response.go.tpl +++ b/http/codegen/templates/partial/single_response.go.tpl @@ -1,14 +1,19 @@ {{- with .Data }} {{- if .ClientBody }} var ( - body {{ .ClientBody.VarName }} + body {{ if .ClientBody.Declaration }}{{ .ClientBody.Declaration.Name }}{{ else }}{{ .ClientBody.VarName }}{{ end }} err error ) err = decoder(resp).Decode(&body) if err != nil { return nil, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) } - {{- if .ClientBody.ValidateRef }} + {{- if and .ClientBody.ValidatorDeclaration .ClientBody.ValidationTarget }} + err = {{ .ClientBody.ValidatorDeclaration.Name }}({{ .ClientBody.ValidationTarget }}) + if err != nil { + return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- else if .ClientBody.ValidateRef }} {{ .ClientBody.ValidateRef }} if err != nil { return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) diff --git a/http/codegen/templates/partial/sse_format.go.tpl b/http/codegen/templates/partial/sse_format.go.tpl index 1745c21900..f46be3b6f4 100644 --- a/http/codegen/templates/partial/sse_format.go.tpl +++ b/http/codegen/templates/partial/sse_format.go.tpl @@ -1,21 +1,35 @@ -{{- if eq .TypeRef "string" }} - data = {{ .VarName }} -{{- else if eq .TypeRef "boolean" }} - if {{ .VarName }} { +{{/* +sse_format.go.tpl converts one planned service value to SSE event text. Every +type choice is resolved before this template writes the send method. +*/ -}} +{{- $value := .Value }} +{{- if .Encoding.Pointer }} + if {{ .Value }} != nil { + {{- $value = printf "*%s" .Value }} +{{- end }} +{{- if sseString .Encoding }} + data = string({{ $value }}) +{{- else if sseBoolean .Encoding }} + if {{ $value }} { data = "true" } else { data = "false" } -{{- else if eq .TypeRef "bytes" }} - data = string({{ .VarName }}) -{{- else if or (eq .TypeRef "int") (eq .TypeRef "int32") (eq .TypeRef "int64") (eq .TypeRef "uint") (eq .TypeRef "uint32") (eq .TypeRef "uint64") }} - data = fmt.Sprintf("%d", {{ .VarName }}) -{{- else if or (eq .TypeRef "float32") (eq .TypeRef "float64") }} - data = fmt.Sprintf("%g", {{ .VarName }}) +{{- else if sseBytes .Encoding }} + data = string({{ $value }}) +{{- else if or (sseSignedInteger .Encoding) (sseUnsignedInteger .Encoding) }} + data = fmt.Sprintf("%d", {{ $value }}) +{{- else if sseFloat .Encoding }} + data = fmt.Sprintf("%g", {{ $value }}) {{- else }} - byts, err := json.Marshal({{ .VarName }}) + byts, err := json.Marshal({{ $value }}) if err != nil { return err } data = string(byts) -{{- end }} \ No newline at end of file +{{- end }} +{{- if .Encoding.Pointer }} + } else { + hasData = false + } +{{- end }} diff --git a/http/codegen/templates/partial/sse_parse.go.tpl b/http/codegen/templates/partial/sse_parse.go.tpl index abc3995d0c..540944a8ed 100644 --- a/http/codegen/templates/partial/sse_parse.go.tpl +++ b/http/codegen/templates/partial/sse_parse.go.tpl @@ -1,58 +1,87 @@ -{{- if eq .TypeRef "string" }} - {{ .Target }} = dataContent -{{- else if eq .TypeRef "boolean" }} +{{/* +sse_parse.go.tpl rebuilds one planned Go value from SSE event text. The +generated client contains only the conversion required by that value. +*/ -}} +{{- if sseString .Encoding }} + {{- if .TargetPointer }} + {{- if .Encoding.Named }} + value := {{ .Encoding.TypeRef }}({{ .Source }}) + {{- else }} + value := {{ .Source }} + {{- end }} + {{ .Target }} = &value + {{- else if .Encoding.Named }} + {{ .Target }} = {{ .Encoding.TypeRef }}({{ .Source }}) + {{- else }} + {{ .Target }} = {{ .Source }} + {{- end }} +{{- else if sseBoolean .Encoding }} var val bool - val, err = strconv.ParseBool(dataContent) + val, err = strconv.ParseBool({{ .Source }}) if err != nil { return } + {{- if .TargetPointer }} + value := {{ .Encoding.TypeRef }}(val) + {{ .Target }} = &value + {{- else if .Encoding.Named }} + {{ .Target }} = {{ .Encoding.TypeRef }}(val) + {{- else }} {{ .Target }} = val -{{- else if eq .TypeRef "bytes" }} - {{ .Target }} = []byte(dataContent) -{{- else if or (eq .TypeRef "int") (eq .TypeRef "int32") }} + {{- end }} +{{- else if sseBytes .Encoding }} + {{- if .TargetPointer }} + value := {{ .Encoding.TypeRef }}([]byte({{ .Source }})) + {{ .Target }} = &value + {{- else if .Encoding.Named }} + {{ .Target }} = {{ .Encoding.TypeRef }}([]byte({{ .Source }})) + {{- else }} + {{ .Target }} = []byte({{ .Source }}) + {{- end }} +{{- else if sseSignedInteger .Encoding }} var val int64 - val, err = strconv.ParseInt(dataContent, 10, 0) + val, err = strconv.ParseInt({{ .Source }}, 10, {{ sseBitSize .Encoding }}) if err != nil { return } - {{ .Target }} = {{ .TypeRef }}(val) -{{- else if eq .TypeRef "int64" }} - {{ .Target }}, err = strconv.ParseInt(dataContent, 10, 64) - if err != nil { - return - } -{{- else if or (eq .TypeRef "uint") (eq .TypeRef "uint32") }} + {{- if .TargetPointer }} + value := {{ .Encoding.TypeRef }}(val) + {{ .Target }} = &value + {{- else }} + {{ .Target }} = {{ .Encoding.TypeRef }}(val) + {{- end }} +{{- else if sseUnsignedInteger .Encoding }} var val uint64 - val, err = strconv.ParseUint(dataContent, 10, 0) + val, err = strconv.ParseUint({{ .Source }}, 10, {{ sseBitSize .Encoding }}) if err != nil { return } - {{ .Target }} = {{ .TypeRef }}(val) -{{- else if eq .TypeRef "uint64" }} - {{ .Target }}, err = strconv.ParseUint(dataContent, 10, 64) - if err != nil { - return - } -{{- else if eq .TypeRef "float32" }} + {{- if .TargetPointer }} + value := {{ .Encoding.TypeRef }}(val) + {{ .Target }} = &value + {{- else }} + {{ .Target }} = {{ .Encoding.TypeRef }}(val) + {{- end }} +{{- else if sseFloat .Encoding }} var val float64 - val, err = strconv.ParseFloat(dataContent, 32) - if err != nil { - return - } - {{ .Target }} = float32(val) -{{- else if eq .TypeRef "float64" }} - {{ .Target }}, err = strconv.ParseFloat(dataContent, 64) + val, err = strconv.ParseFloat({{ .Source }}, {{ sseBitSize .Encoding }}) if err != nil { return } + {{- if .TargetPointer }} + value := {{ .Encoding.TypeRef }}(val) + {{ .Target }} = &value + {{- else }} + {{ .Target }} = {{ .Encoding.TypeRef }}(val) + {{- end }} {{- else }} - // Use user-provided decoder for complex types + // The configured decoder handles structured event data. respBody := &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), + Body: io.NopCloser(bytes.NewReader([]byte({{ .Source }}))), } err = s.decoder(respBody).Decode(&{{ .Target }}) if err != nil { return } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/http/codegen/templates/partial/websocket_upgrade.go.tpl b/http/codegen/templates/partial/websocket_upgrade.go.tpl index 86cbb06e87..18944f7f0c 100644 --- a/http/codegen/templates/partial/websocket_upgrade.go.tpl +++ b/http/codegen/templates/partial/websocket_upgrade.go.tpl @@ -1,13 +1,13 @@ {{ printf "Upgrade the HTTP connection to a websocket connection only once. Connection upgrade is done here so that authorization logic in the endpoint is executed before calling the actual service method which may call %s()." .Function | comment }} s.once.Do(func() { - {{- if and .ViewedResult (eq .Function "Send") }} + {{- if and .ViewedResult (or (eq .Function "Send") (eq .Function "Close")) }} {{- if not .ViewedResult.ViewName }} respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) + respHdr.Add("goa-view", view) {{- end }} {{- end }} var conn *websocket.Conn - {{- if eq .Function "Send" }} + {{- if or (eq .Function "Send") (eq .Function "Close") }} {{- if .ViewedResult }} {{- if not .ViewedResult.ViewName }} conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) diff --git a/http/codegen/templates/path.go.tpl b/http/codegen/templates/path.go.tpl index 30d0663e67..b555c2a341 100644 --- a/http/codegen/templates/path.go.tpl +++ b/http/codegen/templates/path.go.tpl @@ -1,5 +1,5 @@ {{ range .Routes }}// {{ .PathInit.Description }} -func {{ .PathInit.Name }}({{ range .PathInit.ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .PathInit.ReturnTypeRef }} { +func {{ if $.Client }}{{ .PathInit.ClientDeclaration.Name }}{{ else }}{{ .PathInit.Declaration.Name }}{{ end }}({{ range .PathInit.ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .PathInit.ReturnTypeRef }} { {{- .PathInit.ServerCode }} } {{ end }} diff --git a/http/codegen/templates/request_builder.go.tpl b/http/codegen/templates/request_builder.go.tpl index 5fb72f304a..1eb1935bd1 100644 --- a/http/codegen/templates/request_builder.go.tpl +++ b/http/codegen/templates/request_builder.go.tpl @@ -1,4 +1,4 @@ {{ comment .RequestInit.Description }} -func (c *{{ .ClientStruct }}) {{ .RequestInit.Name }}(ctx context.Context, {{ range .RequestInit.ClientArgs }}{{ .VarName }} {{ .TypeRef }},{{ end }}) (*http.Request, error) { +func (c *{{ .ClientStructDeclaration.Name }}) {{ .RequestInit.Declaration.Name }}(ctx context.Context, {{ range .RequestInit.ClientArgs }}{{ .VarName }} {{ .TypeRef }},{{ end }}) (*http.Request, error) { {{- .RequestInit.ClientCode }} } diff --git a/http/codegen/templates/request_decoder.go.tpl b/http/codegen/templates/request_decoder.go.tpl index 0b4570c7f1..7c33d13257 100644 --- a/http/codegen/templates/request_decoder.go.tpl +++ b/http/codegen/templates/request_decoder.go.tpl @@ -1,21 +1,13 @@ -{{ printf "%s returns a decoder for requests sent to the %s %s endpoint." .RequestDecoder .ServiceName .Method.Name | comment }} -func {{ .RequestDecoder }}(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request{{ if .IsJSONRPC }}, *jsonrpc.RawRequest{{ end }}) ({{ .Payload.Ref }}, error) { +{{ printf "%s returns a decoder for requests sent to the %s %s endpoint." .RequestDecoderDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .RequestDecoderDeclaration.Name }}(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request{{ if .IsJSONRPC }}, *jsonrpc.RawRequest{{ end }}) ({{ .Payload.Ref }}, error) { return func(r *http.Request{{ if .IsJSONRPC }}, req *jsonrpc.RawRequest{{ end }}) ({{ .Payload.Ref }}, error) { {{- if .IsJSONRPC }} r.Body = io.NopCloser(bytes.NewReader(req.Params)) {{- end }} var payload {{ .Payload.Ref }} -{{- if .MultipartRequestDecoder }} - if err := decoder(r).Decode(&payload); err != nil { - var gerr *goa.ServiceError - if errors.As(err, &gerr) { - return payload, gerr - } - return payload, goa.DecodePayloadError(err.Error()) - } -{{- else if .Payload.Request.ServerBody }} +{{- if .Payload.Request.ServerBody }} var ( - body {{ .Payload.Request.ServerBody.VarName }} + body {{ if .Payload.Request.ServerBody.Declaration }}{{ .Payload.Request.ServerBody.Declaration.Name }}{{ else }}{{ .Payload.Request.ServerBody.VarName }}{{ end }} err error ) err = decoder(r).Decode(&body) @@ -38,14 +30,18 @@ func {{ .RequestDecoder }}(mux goahttp.Muxer, decoder func(*http.Request) goahtt } {{- end }} } - {{- if .Payload.Request.ServerBody.ValidateRef }} + {{- if and .Payload.Request.ServerBody.ValidatorDeclaration .Payload.Request.ServerBody.ValidationTarget }} + err = {{ .Payload.Request.ServerBody.ValidatorDeclaration.Name }}({{ .Payload.Request.ServerBody.ValidationTarget }}) + if err != nil { + return payload, err + } + {{- else if .Payload.Request.ServerBody.ValidateRef }} {{ .Payload.Request.ServerBody.ValidateRef }} if err != nil { return payload, err } {{- end }} {{- end }} -{{- if not .MultipartRequestDecoder }} {{- template "partial_request_elements" .Payload.Request }} {{- if .Payload.Request.MustValidate }} if err != nil { @@ -53,13 +49,12 @@ func {{ .RequestDecoder }}(mux goahttp.Muxer, decoder func(*http.Request) goahtt } {{- end }} {{- if .Payload.Request.PayloadInit }} - payload = {{ .Payload.Request.PayloadInit.Name }}({{ range .Payload.Request.PayloadInit.ServerArgs }}{{ .Ref }}, {{ end }}) + payload = {{ .Payload.Request.PayloadInit.Declaration.Name }}({{ range .Payload.Request.PayloadInit.ServerArgs }}{{ .Ref }}, {{ end }}) {{- else if .Payload.DecoderReturnValue }} payload = {{ .Payload.DecoderReturnValue }} {{- else }} payload = body {{- end }} -{{- end }} {{- if .BasicScheme }}{{ with .BasicScheme }} user, pass, {{ if or .UsernameRequired .PasswordRequired }}ok{{ else }}_{{ end }} := r.BasicAuth() {{- if or .UsernameRequired .PasswordRequired}} diff --git a/http/codegen/templates/request_encoder.go.tpl b/http/codegen/templates/request_encoder.go.tpl index da463f0b68..5afc55ca32 100644 --- a/http/codegen/templates/request_encoder.go.tpl +++ b/http/codegen/templates/request_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ if and .IsJSONRPC (not .Payload.Ref) }}{{ printf "%s returns an encoder for requests sent to the %s service %s JSON-RPC method." .RequestEncoder .ServiceName .Method.Name | comment }}{{ else }}{{ printf "%s returns an encoder for requests sent to the %s %s server." .RequestEncoder .ServiceName .Method.Name | comment }}{{ end }} -func {{ .RequestEncoder }}(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { +{{ if and .IsJSONRPC (not .Payload.Ref) }}{{ printf "%s returns an encoder for requests sent to the %s service %s JSON-RPC method." .RequestEncoderDeclaration.Name .ServiceName .Method.Name | comment }}{{ else }}{{ printf "%s returns an encoder for requests sent to the %s %s server." .RequestEncoderDeclaration.Name .ServiceName .Method.Name | comment }}{{ end }} +func {{ .RequestEncoderDeclaration.Name }}(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { return func(req *http.Request, v any) error { {{- if and .IsJSONRPC (not .Payload.Ref) }} {{- template "partial_jsonrpc_request_envelope" . }} @@ -9,9 +9,9 @@ func {{ .RequestEncoder }}(encoder func(*http.Request) goahttp.Encoder) func(*ht return nil {{- else }} {{- if .Method.SkipRequestBodyEncodeDecode }} - data, ok := v.(*{{ requestStructPkg .Method .ServicePkgName }}.{{ .Method.RequestStruct }}) + data, ok := v.(*{{ .ServicePkgName }}.{{ .Method.RequestStruct }}) if !ok { - return goahttp.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "*{{ requestStructPkg .Method .ServicePkgName }}.{{ .Method.RequestStruct }}", v) + return goahttp.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "*{{ .ServicePkgName }}.{{ .Method.RequestStruct }}", v) } p := data.Payload {{- else }} @@ -126,13 +126,11 @@ func {{ .RequestEncoder }}(encoder func(*http.Request) goahttp.Encoder) func(*ht {{- if .FieldPointer }} if p.{{ .FieldName }} != nil { {{- end }} - values.Add("{{ .HTTPName }}", - {{- if or (eq .Type.Name "bytes") (and (isAlias .FieldType) (eq (underlyingType .FieldType).Name "string")) }} string( - {{- else if not (eq .Type.Name "string") }} fmt.Sprintf("%v", + {{- $target := printf "p.%s" .FieldName }} + {{- if .FieldPointer }} + {{- $target = printf "*p.%s" .FieldName }} {{- end }} - {{- if .FieldPointer }}*{{ end }}p.{{ .FieldName }} - {{- if or (eq .Type.Name "bytes") (not (eq .Type.Name "string")) (and (isAlias .FieldType) (eq (underlyingType .FieldType).Name "string")) }}) - {{- end }}) + values.Add("{{ .HTTPName }}", {{ template "partial_client_type_expression" (typeConversionData .Type .FieldType "" $target) }}) {{- if .FieldPointer }} } {{- end }} @@ -156,7 +154,7 @@ func {{ .RequestEncoder }}(encoder func(*http.Request) goahttp.Encoder) func(*ht } {{- else if .Payload.Request.ClientBody }} {{- if .Payload.Request.ClientBody.Init }} - {{ if .IsJSONRPC }}b{{ else }}body{{ end }} := {{ .Payload.Request.ClientBody.Init.Name }}({{ range .Payload.Request.ClientBody.Init.ClientArgs }}{{ if .FieldPointer }}&{{ end }}{{ .VarName }}, {{ end }}) + {{ if .IsJSONRPC }}b{{ else }}body{{ end }} := {{ .Payload.Request.ClientBody.Init.Declaration.Name }}({{ range .Payload.Request.ClientBody.Init.ClientArgs }}{{ if .FieldPointer }}&{{ end }}{{ .VarName }}, {{ end }}) {{- else }} {{ if .IsJSONRPC }}b{{ else }}body{{ end }} := p{{ if .Payload.Request.PayloadAttr }}.{{ .Payload.Request.PayloadAttr }}{{ end }} {{- end }} diff --git a/http/codegen/templates/request_init.go.tpl b/http/codegen/templates/request_init.go.tpl index a3031e072f..c7a191c5ef 100644 --- a/http/codegen/templates/request_init.go.tpl +++ b/http/codegen/templates/request_init.go.tpl @@ -53,7 +53,7 @@ scheme = "wss" } {{- end }} - u := &url.URL{Scheme: {{ if .IsWebSocket }}scheme{{ else }}c.scheme{{ end }}, Host: c.host, Path: {{ .PathInit.Name }}({{ range .Args }}{{ .Ref }}, {{ end }})} + u := &url.URL{Scheme: {{ if .IsWebSocket }}scheme{{ else }}c.scheme{{ end }}, Host: c.host, Path: {{ .PathInit.ClientDeclaration.Name }}({{ range .Args }}{{ .Ref }}, {{ end }})} req, err := http.NewRequest("{{ .Verb }}", u.String(), {{ if .RequestStruct }}body{{ else }}nil{{ end }}) if err != nil { return nil, goahttp.ErrInvalidURL("{{ .ServiceName }}", "{{ .EndpointName }}", u.String(), err) diff --git a/http/codegen/templates/response_decoder.go.tpl b/http/codegen/templates/response_decoder.go.tpl index 17ea5eeae8..edb3f5ff2c 100644 --- a/http/codegen/templates/response_decoder.go.tpl +++ b/http/codegen/templates/response_decoder.go.tpl @@ -1,6 +1,6 @@ -{{ printf "%s returns a decoder for responses returned by the %s %s endpoint. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoder .ServiceName .Method.Name | comment }} +{{ printf "%s returns a decoder for responses returned by the %s %s endpoint. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoderDeclaration.Name .ServiceName .Method.Name | comment }} {{- if .Errors }} -{{ printf "%s may return the following errors:" .ResponseDecoder | comment }} +{{ printf "%s may return the following errors:" .ResponseDecoderDeclaration.Name | comment }} {{- range $gerr := .Errors }} {{- range $errors := .Errors }} // - {{ printf "%q" .Name }} (type {{ .Ref }}): {{ .Response.StatusCode }}{{ if .Response.Description }}, {{ .Response.Description }}{{ end }} @@ -8,20 +8,26 @@ {{- end }} // - error: internal error {{- end }} -func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { +func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) {{ if .Method.SkipResponseBodyEncodeDecode }}(any, error){{ else }}(result any, decodeErr error){{ end }} { + {{- if not .Method.SkipResponseBodyEncodeDecode }} + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() - } - {{- if not .Method.SkipResponseBodyEncodeDecode }} else { - defer resp.Body.Close() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err)) + } + }() } {{- end }} switch resp.StatusCode { @@ -30,7 +36,7 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} {{- if .ViewedResult }} - p := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + p := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- if .TagName }} tmp := {{ printf "%q" .TagValue }} p.{{ .TagName }} = &tmp @@ -42,13 +48,13 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- end }} vres := {{ if not $.Method.ViewedResult.IsCollection }}&{{ end }}{{ $.Method.ViewedResult.ViewsPkg}}.{{ $.Method.ViewedResult.VarName }}{Projected: p, View: view} {{- if .ClientBody }} - if err = {{ $.Method.ViewedResult.ViewsPkg}}.Validate{{ $.Method.Result }}(vres); err != nil { + if err = {{ $.Method.ViewedResult.ViewsPkg}}.{{ $.Method.ViewedResult.Validate.Declaration.Name }}(vres); err != nil { return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) } {{- end }} - res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Name }}(vres) + res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Declaration.Name }}(vres) {{- else }} - res := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + res := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- end }} {{- if and .TagName (not .ViewedResult) }} {{- if .TagPointer }} @@ -79,7 +85,7 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- with .Response }} {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - return nil, {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + return nil, {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- else if .ClientBody }} return nil, body {{- else }} @@ -88,14 +94,17 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- end }} {{- end }} default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } return nil, goahttp.ErrInvalidResponse({{ printf "%q" $.ServiceName }}, {{ printf "%q" $.Method.Name }}, resp.StatusCode, string(body)) } {{- else }} {{- with (index .Errors 0).Response }} {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - return nil, {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + return nil, {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- else if .ClientBody }} return nil, body {{- else }} @@ -105,7 +114,10 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- end }} {{- end }} default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } return nil, goahttp.ErrInvalidResponse({{ printf "%q" .ServiceName }}, {{ printf "%q" .Method.Name }}, resp.StatusCode, string(body)) } } diff --git a/http/codegen/templates/response_encoder.go.tpl b/http/codegen/templates/response_encoder.go.tpl index 5b5ab0b5a9..3399652511 100644 --- a/http/codegen/templates/response_encoder.go.tpl +++ b/http/codegen/templates/response_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s returns an encoder for responses returned by the %s %s endpoint." .ResponseEncoder .ServiceName .Method.Name | comment }} -func {{ .ResponseEncoder }}(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { +{{ printf "%s returns an encoder for responses returned by the %s %s endpoint." .ResponseEncoderDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ResponseEncoderDeclaration.Name }}(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { return func(ctx context.Context, w http.ResponseWriter, v any) error { {{- if .Result.MustInit }} {{- if .Method.ViewedResult }} diff --git a/http/codegen/templates/server_body_init.go.tpl b/http/codegen/templates/server_body_init.go.tpl index ee77042eb9..11c44ccb2d 100644 --- a/http/codegen/templates/server_body_init.go.tpl +++ b/http/codegen/templates/server_body_init.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}({{ range .ServerArgs }}{{ .VarName }} {{.TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{ range .ServerArgs }}{{ .VarName }} {{.TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{ .ServerCode }} return body } diff --git a/http/codegen/templates/server_configure.go.tpl b/http/codegen/templates/server_configure.go.tpl index a65e268891..69c71dc3e9 100644 --- a/http/codegen/templates/server_configure.go.tpl +++ b/http/codegen/templates/server_configure.go.tpl @@ -5,36 +5,35 @@ // responses. var ( {{- range .Services }} - {{ .Service.VarName }}Server *{{.Service.PkgName}}svr.Server + {{ .Service.VarName }}Server *{{ .ServerPkgName }}.{{ .ServerStructDeclaration.Name }} {{- end }} {{- range .JSONRPCServices }} - {{ .Service.VarName }}JSONRPCServer *{{ .Service.PkgName }}jssvr.Server + {{ .Service.VarName }}JSONRPCServer *{{ .ServerPkgName }}.{{ .ServerStructDeclaration.Name }} {{- end }} ) { eh := errorHandler(ctx) - {{- if or (needDialer .Services) (needDialer .JSONRPCServices) }} + {{- if needDialer .Services }} upgrader := &websocket.Upgrader{} {{- end }} {{- range $svc := .Services }} {{- if .Endpoints }} - {{ .Service.VarName }}Server = {{ .Service.PkgName }}svr.New({{ .Service.VarName }}Endpoints, mux, dec, enc, eh, nil{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}{{ range .Endpoints }}{{ if .MultipartRequestDecoder }}, {{ $.APIPkg }}.{{ .MultipartRequestDecoder.FuncName }}{{ end }}{{ end }}{{ range .FileServers }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}({{ .Service.VarName }}Endpoints, mux, dec, enc, eh, nil{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}{{ range .Endpoints }}{{ if .MultipartRequestDecoder }}, {{ $.APIPkg }}.{{ .MultipartRequestDecoder.FuncDeclaration.Name }}{{ end }}{{ end }}{{ range .FileServers }}, nil{{ end }}) {{- else }} - {{ .Service.VarName }}Server = {{ .Service.PkgName }}svr.New(nil, mux, dec, enc, eh, nil{{ range .FileServers }}, nil{{ end }}) - {{- end }} - {{- end }} - {{- range $svcData := .JSONRPCServices }} - {{- if .Endpoints }} - {{- $svc := . }} - {{ .Service.VarName }}JSONRPCServer = {{ .Service.PkgName }}jssvr.New({{ if hasWebSocket $svc }}{{ .Service.VarName }}Svc.HandleStream, {{ end }}{{ .Service.VarName }}Endpoints, mux, dec, enc, eh{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}(nil, mux, dec, enc, eh, nil{{ range .FileServers }}, nil{{ end }}) {{- end }} {{- end }} + {{- range .JSONRPCServices }} + {{- if .Endpoints }} + {{ .Service.VarName }}JSONRPCServer = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}({{ .Service.VarName }}Endpoints, mux, dec, enc, eh) + {{- end }} + {{- end }} } // Configure the mux. {{- range .Services }} - {{ .Service.PkgName }}svr.Mount(mux, {{ .Service.VarName }}Server) + {{ .ServerPkgName }}.{{ .MountServerDeclaration.Name }}(mux, {{ .Service.VarName }}Server) {{- end }} {{- range .JSONRPCServices }} - {{ .Service.PkgName }}jssvr.Mount(mux, {{ .Service.VarName }}JSONRPCServer) + {{ .ServerPkgName }}.{{ .MountServerDeclaration.Name }}(mux, {{ .Service.VarName }}JSONRPCServer) {{- end }} diff --git a/http/codegen/templates/server_handler.go.tpl b/http/codegen/templates/server_handler.go.tpl index 6428a945c9..9091126752 100644 --- a/http/codegen/templates/server_handler.go.tpl +++ b/http/codegen/templates/server_handler.go.tpl @@ -1,5 +1,8 @@ -{{ printf "%s configures the mux to serve the %q service %q endpoint." .MountHandler .ServiceName .Method.Name | comment }} -func {{ .MountHandler }}(mux goahttp.Muxer, h http.Handler) { +{{ printf "%s configures the mux to serve the %q service %q endpoint." .MountHandlerDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .MountHandlerDeclaration.Name }}(mux goahttp.Muxer, h http.Handler) { + {{- if .ServerHandlerWrappers }} + h = {{ range .ServerHandlerWrappers }}{{ .Name }}({{ end }}h{{ range .ServerHandlerWrappers }}){{ end }} + {{- end }} f, ok := h.(http.HandlerFunc) if !ok { f = func(w http.ResponseWriter, r *http.Request) { diff --git a/http/codegen/templates/server_handler_init.go.tpl b/http/codegen/templates/server_handler_init.go.tpl index 08ff40ffe0..c2f8d0dbf2 100644 --- a/http/codegen/templates/server_handler_init.go.tpl +++ b/http/codegen/templates/server_handler_init.go.tpl @@ -15,13 +15,13 @@ func {{ .HandlerInit }}( var ( {{- end }} {{- if mustDecodeRequest . }} - decodeRequest = {{ .RequestDecoder }}(mux, decoder) + decodeRequest = {{ .RequestDecoderDeclaration.Name }}(mux, decoder) {{- end }} {{- if not (or .Redirect (isWebSocketEndpoint .) (and (isSSEEndpoint .) (not .HasMixedResults))) }} - encodeResponse = {{ .ResponseEncoder }}(encoder) + encodeResponse = {{ .ResponseEncoderDeclaration.Name }}(encoder) {{- end }} {{- if (or (mustDecodeRequest .) (not .Redirect) .Method.SkipResponseBodyEncodeDecode) }} - encodeError = {{ if .Errors }}{{ .ErrorEncoder }}{{ else }}goahttp.ErrorEncoder{{ end }}(encoder, formatter) + encodeError = {{ if .Errors }}{{ .ErrorEncoderDeclaration.Name }}{{ else }}goahttp.ErrorEncoder{{ end }}(encoder, formatter) {{- end }} {{- if (or (mustDecodeRequest .) (not (or .Redirect (isWebSocketEndpoint .) (and (isSSEEndpoint .) (not .HasMixedResults)))) (not .Redirect) .Method.SkipResponseBodyEncodeDecode) }} ) @@ -63,7 +63,7 @@ func {{ .HandlerInit }}( } {{- end }} v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - Stream: &{{ .SSE.StructName }}{ + Stream: &{{ .SSE.StructDeclaration.Name }}{ w: w, r: r, }, @@ -73,6 +73,13 @@ func {{ .HandlerInit }}( } _, err = endpoint(ctx, v) if err != nil { + stream := v.Stream.(*{{ .SSE.StructDeclaration.Name }}) + if stream.attempted { + if errhandler != nil { + errhandler(ctx, w, err) + } + return + } if err := encodeError(ctx, w, err); err != nil && errhandler != nil { errhandler(ctx, w, err) } @@ -98,7 +105,7 @@ func {{ .HandlerInit }}( // In the standard (non-SSE) mode, Stream discards events and the service // must return the synchronous result. v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - Stream: &discard{{ .Method.VarName }}ServerStream{}, + Stream: &{{ .DiscardStreamDeclaration.Name }}{}, {{- if .Payload.Ref }} Payload: payload, {{- end }} @@ -176,7 +183,7 @@ func {{ .HandlerInit }}( var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - Stream: &{{ .ServerWebSocket.VarName }}{ + Stream: &{{ .ServerWebSocket.VarDeclaration.Name }}{ upgrader: upgrader, configurer: configurer, cancel: cancel, @@ -205,7 +212,7 @@ func {{ .HandlerInit }}( } {{- end }} v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - Stream: &{{ .SSE.StructName }}{ + Stream: &{{ .SSE.StructDeclaration.Name }}{ w: w, r: r, }, @@ -225,11 +232,11 @@ func {{ .HandlerInit }}( {{- if not .Redirect }} if err != nil { {{- if isWebSocketEndpoint . }} - var stream *{{ .ServerWebSocket.VarName }} + var stream *{{ .ServerWebSocket.VarDeclaration.Name }} if wrapper, ok := v.Stream.(interface{ Unwrap() any }); ok { - stream = wrapper.Unwrap().(*{{ .ServerWebSocket.VarName }}) + stream = wrapper.Unwrap().(*{{ .ServerWebSocket.VarDeclaration.Name }}) } else { - stream = v.Stream.(*{{ .ServerWebSocket.VarName }}) + stream = v.Stream.(*{{ .ServerWebSocket.VarDeclaration.Name }}) } if stream != nil && stream.conn != nil { // Response writer has been hijacked, do not encode the error @@ -239,6 +246,15 @@ func {{ .HandlerInit }}( return } {{- end }} + {{- if isSSEEndpoint . }} + stream := v.Stream.(*{{ .SSE.StructDeclaration.Name }}) + if stream.attempted { + if errhandler != nil { + errhandler(ctx, w, err) + } + return + } + {{- end }} if err := encodeError(ctx, w, err); err != nil && errhandler != nil { errhandler(ctx, w, err) } @@ -301,24 +317,24 @@ func {{ .HandlerInit }}( {{- if .HasMixedResults }} -// discard{{ .Method.VarName }}ServerStream implements the {{ .SSE.Interface }} +// {{ .DiscardStreamDeclaration.Name }} implements the {{ .SSE.Interface }} // interface and drops all events. It is used for mixed results endpoints in -// unary (non-SSE) mode so service implementations can use the stream parameter -// without nil checks. -type discard{{ .Method.VarName }}ServerStream struct{} +// regular HTTP requests so service implementations can use the stream +// parameter without nil checks. +type {{ .DiscardStreamDeclaration.Name }} struct{} // {{ .SSE.SendName }} discards the event. -func (s *discard{{ .Method.VarName }}ServerStream) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error { +func (s *{{ .DiscardStreamDeclaration.Name }}) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error { return nil } // {{ .SSE.SendWithContextName }} discards the event. -func (s *discard{{ .Method.VarName }}ServerStream) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error { +func (s *{{ .DiscardStreamDeclaration.Name }}) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error { return nil } // Close is a no-op. -func (s *discard{{ .Method.VarName }}ServerStream) Close() error { +func (s *{{ .DiscardStreamDeclaration.Name }}) Close() error { return nil } {{- end }} diff --git a/http/codegen/templates/server_init.go.tpl b/http/codegen/templates/server_init.go.tpl index 0562b3bc5e..894a3b903a 100644 --- a/http/codegen/templates/server_init.go.tpl +++ b/http/codegen/templates/server_init.go.tpl @@ -1,6 +1,6 @@ -{{ printf "%s instantiates HTTP handlers for all the %s service endpoints using the provided encoder and decoder. The handlers are mounted on the given mux using the HTTP verb and path defined in the design. errhandler is called whenever a response fails to be encoded. formatter is used to format errors returned by the service methods prior to encoding. Both errhandler and formatter are optional and can be nil." .ServerInit .Service.Name | comment }} -func {{ .ServerInit }}( - e *{{ .Service.PkgName }}.Endpoints, +{{ printf "%s instantiates HTTP handlers for all the %s service endpoints using the provided encoder and decoder. The handlers are mounted on the given mux using the HTTP verb and path defined in the design. errhandler is called whenever a response fails to be encoded. formatter is used to format errors returned by the service methods prior to encoding. Both errhandler and formatter are optional and can be nil." .ServerInitDeclaration.Name .Service.Name | comment }} +func {{ .ServerInitDeclaration.Name }}( + e *{{ .Service.PkgName }}.{{ .Service.EndpointsDeclaration.Name }}, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, @@ -8,20 +8,20 @@ func {{ .ServerInit }}( formatter func(ctx context.Context, err error) goahttp.Statuser, {{- if hasWebSocket . }} upgrader goahttp.Upgrader, - configurer *ConnConfigurer, + configurer *{{ .ServerConnConfigurerDeclaration.Name }}, {{- end }} {{- range .Endpoints }} {{- if .MultipartRequestDecoder }} - {{ .MultipartRequestDecoder.VarName }} {{ .MultipartRequestDecoder.FuncName }}, + {{ .MultipartRequestDecoder.VarName }} {{ .MultipartRequestDecoder.FuncDeclaration.Name }}, {{- end }} {{- end }} {{- range .FileServers }} {{ .ArgName }} http.FileSystem, {{- end }} -) *{{ .ServerStruct }} { +) *{{ .ServerStructDeclaration.Name }} { {{- if hasWebSocket . }} if configurer == nil { - configurer = &ConnConfigurer{} + configurer = &{{ .ServerConnConfigurerDeclaration.Name }}{} } {{- end }} {{- range .FileServers }} @@ -32,10 +32,10 @@ func {{ .ServerInit }}( {{- if not .IsDir }} {{- $prefix = dir $prefix }} {{- end }} - {{ .ArgName }} = appendPrefix({{ .ArgName }}, "{{ $prefix }}") + {{ .ArgName }} = {{ $.AppendPrefixDeclaration.Name }}({{ .ArgName }}, "{{ $prefix }}") {{- end }} - return &{{ .ServerStruct }}{ - Mounts: []*{{ .MountPointStruct }}{ + return &{{ .ServerStructDeclaration.Name }}{ + Mounts: []*{{ .MountPointStructDeclaration.Name }}{ {{- range $e := .Endpoints }} {{- range $e.Routes }} {"{{ $e.Method.VarName }}", "{{ .Verb }}", "{{ .Path }}"}, @@ -47,6 +47,11 @@ func {{ .ServerInit }}( {"Serve {{ $filepath }}", "GET", "{{ . }}"}, {{- end }} {{- end }} + {{- range .ServerMounts }} + {{- range .MountPoints }} + { {{ printf "%q" .Method }}, {{ printf "%q" .Verb }}, {{ printf "%q" .Pattern }} }, + {{- end }} + {{- end }} }, {{- range .Endpoints }} {{ .Method.VarName }}: {{ .HandlerInit }}(e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitName }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }}), diff --git a/http/codegen/templates/server_method_names.go.tpl b/http/codegen/templates/server_method_names.go.tpl index aec727ee7d..d6a7ddc2aa 100644 --- a/http/codegen/templates/server_method_names.go.tpl +++ b/http/codegen/templates/server_method_names.go.tpl @@ -1,2 +1,2 @@ {{ printf "MethodNames returns the methods served." | comment }} -func (s *{{ .ServerStruct }}) MethodNames() []string { return {{ .Service.PkgName }}.MethodNames[:] } +func (s *{{ .ServerStructDeclaration.Name }}) MethodNames() []string { return {{ .Service.PkgName }}.{{ .Service.MethodNamesDeclaration.Name }}[:] } diff --git a/http/codegen/templates/server_mount.go.tpl b/http/codegen/templates/server_mount.go.tpl index 01b4fc294a..fb7ab6edd5 100644 --- a/http/codegen/templates/server_mount.go.tpl +++ b/http/codegen/templates/server_mount.go.tpl @@ -1,15 +1,15 @@ -{{ printf "%s configures the mux to serve the %s endpoints." .MountServer .Service.Name | comment }} -func {{ .MountServer }}(mux goahttp.Muxer, h *{{ .ServerStruct }}) { +{{ printf "%s configures the mux to serve the %s endpoints." .MountServerDeclaration.Name .Service.Name | comment }} +func {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer, h *{{ .ServerStructDeclaration.Name }}) { {{- range .Endpoints }} - {{ .MountHandler }}(mux, h.{{ .Method.VarName }}) + {{ if .MountHandlerDeclaration }}{{ .MountHandlerDeclaration.Name }}{{ else }}{{ .MountHandler }}{{ end }}(mux, h.{{ .Method.VarName }}) {{- end }} {{- range .FileServers }} {{- if .Redirect }} - {{ .MountHandler }}(mux, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + {{ .MountHandlerDeclaration.Name }}(mux, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "{{ .Redirect.URL }}", {{ .Redirect.StatusCode }}) })) {{- else }} - {{- $mountHandler := .MountHandler }} + {{- $mountHandler := .MountHandlerDeclaration.Name }} {{- $varName := .VarName }} {{- $isDir := .IsDir }} {{- range .RequestPaths }} @@ -18,16 +18,19 @@ func {{ .MountServer }}(mux goahttp.Muxer, h *{{ .ServerStruct }}) { {{- $stripped = (dir $stripped) }} {{- end }} {{- if eq $stripped "/" }} - {{ $mountHandler }}(mux, h.{{ $varName }}) + {{ $mountHandler }}(mux, h.{{ $varName }}) {{- else }} {{ $mountHandler }}(mux, http.StripPrefix("{{ $stripped }}", h.{{ $varName }})) {{- end }} {{- end }} {{- end }} {{- end }} + {{- range .ServerMounts }} + {{ .Declaration.Name }}(mux) + {{- end }} } -{{ printf "%s configures the mux to serve the %s endpoints." .MountServer .Service.Name | comment }} -func (s *{{ .ServerStruct }}) {{ .MountServer }}(mux goahttp.Muxer) { - {{ .MountServer }}(mux, s) +{{ printf "%s configures the mux to serve the %s endpoints." .MountServerDeclaration.Name .Service.Name | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer) { + {{ .MountServerDeclaration.Name }}(mux, s) } diff --git a/http/codegen/templates/server_service.go.tpl b/http/codegen/templates/server_service.go.tpl index 744fae2dea..c8337b8caa 100644 --- a/http/codegen/templates/server_service.go.tpl +++ b/http/codegen/templates/server_service.go.tpl @@ -1,2 +1,2 @@ {{ printf "%s returns the name of the service served." .ServerService | comment }} -func (s *{{ .ServerStruct }}) {{ .ServerService }}() string { return "{{ .Service.Name }}" } +func (s *{{ .ServerStructDeclaration.Name }}) {{ .ServerService }}() string { return "{{ .Service.Name }}" } diff --git a/http/codegen/templates/server_sse.go.tpl b/http/codegen/templates/server_sse.go.tpl index 571f98f62a..98a8bead95 100644 --- a/http/codegen/templates/server_sse.go.tpl +++ b/http/codegen/templates/server_sse.go.tpl @@ -1,20 +1,115 @@ -{{ printf "%s implements the %s interface using Server-Sent Events." .SSE.StructName .SSE.Interface | comment }} -type {{ .SSE.StructName }} struct { +{{/* +server_sse.go.tpl writes the HTTP server stream for one SSE endpoint. The plan +provides the exact response value and selected view used for each event. +*/ -}} +{{ printf "%s implements the %s interface using Server-Sent Events." .SSE.StructDeclaration.Name .SSE.Interface | comment }} +type {{ .SSE.StructDeclaration.Name }} struct { {{ comment "once ensures the headers are written once." }} once sync.Once {{ comment "w is the HTTP response writer used to send the SSE events." }} w http.ResponseWriter {{ comment "r is the HTTP request." }} r *http.Request + {{ comment "attempted is true after this stream writes the HTTP success status." }} + attempted bool + {{- if .SSE.VariableView }} + {{ comment "view is the result view selected for events in this HTTP response." }} + view string + {{ comment "sentView is the result view used by the first event. Later sends must use the same view." }} + sentView string + {{- end }} +} + +{{- if .SSE.VariableView }} +{{ comment "SetView selects the result view used by subsequent sends on this stream." }} +func (s *{{ .SSE.StructDeclaration.Name }}) SetView(view string) { + s.view = view } +{{- end }} {{ printf "%s %s" .SSE.SendName .SSE.SendDesc | comment }} -func (s *{{ .SSE.StructName }}) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error { +func (s *{{ .SSE.StructDeclaration.Name }}) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error { return s.{{ .SSE.SendWithContextName }}(context.Background(), v) } {{ printf "%s %s" .SSE.SendWithContextName .SSE.SendWithContextDesc | comment }} -func (s *{{ .SSE.StructName }}) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error { +func (s *{{ .SSE.StructDeclaration.Name }}) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error { + {{- if .SSE.VariableView }} + view := s.view + if view == "" { + view = {{ printf "%q" .SSE.DefaultView }} + } + switch view { + {{- range .Method.ViewedResult.Views }} + case {{ printf "%q" .Name }}: + {{- end }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} }) + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + {{- else if .Method.ViewedResult }} + view := {{ printf "%q" .Method.ViewedResult.ViewName }} + {{- end }} + + {{- if .Method.ViewedResult }} + res := {{ .ServicePkgName }}.{{ .Method.ViewedResult.Init.Declaration.Name }}(v, view) + {{- if or .SSE.IDField .SSE.EventField .SSE.RetryField (not .SSE.HasResponseBody) }} + projected := res.Projected + {{- end }} + {{- else }} + res := v + {{- end }} + + var data string + {{- if .SSE.Data.Pointer }} + hasData := true + {{- end }} + {{- if .SSE.HasResponseBody }} + {{- if .Method.ViewedResult }} + {{- if .SSE.VariableView }} + switch view { + {{- range .SSE.Response.ViewedRepresentations }} + case {{ printf "%q" .View }}: + {{- template "viewed_sse_server_body" dict "Endpoint" $ "Representation" . }} + {{- end }} + } + {{- else }} + {{- range .SSE.Response.ViewedRepresentations }} + {{- template "viewed_sse_server_body" dict "Endpoint" $ "Representation" . }} + {{- end }} + {{- end }} + {{- else }} + {{- if (index .SSE.Response.ServerBody 0).Init }} + body := {{ (index .SSE.Response.ServerBody 0).Init.Declaration.Name }}({{ range (index .SSE.Response.ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) + {{- else }} + body := res + {{- end }} + {{- if .SSE.DataField }} + {{ template "partial_sse_format" dict "Value" (printf "body.%s" .SSE.DataField) "Encoding" .SSE.Data }} + {{- else }} + {{ template "partial_sse_format" dict "Value" "body" "Encoding" .SSE.Data }} + {{- end }} + {{- end }} + {{- else }} + {{- if .SSE.DataField }} + {{- if .Method.ViewedResult }} + {{ template "partial_sse_format" dict "Value" (printf "projected.%s" .SSE.DataField) "Encoding" .SSE.Data }} + {{- else }} + {{ template "partial_sse_format" dict "Value" (printf "res.%s" .SSE.DataField) "Encoding" .SSE.Data }} + {{- end }} + {{- else }} + {{- if .Method.ViewedResult }} + {{ template "partial_sse_format" dict "Value" "projected" "Encoding" .SSE.Data }} + {{- else }} + {{ template "partial_sse_format" dict "Value" "res" "Encoding" .SSE.Data }} + {{- end }} + {{- end }} + {{- end }} + {{- if .SSE.VariableView }} + s.sentView = view + {{- end }} s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -26,104 +121,70 @@ func (s *{{ .SSE.StructName }}) {{ .SSE.SendWithContextName }}(ctx context.Conte if header.Get("Connection") == "" { header.Set("Connection", "keep-alive") } + {{- if .SSE.VariableView }} + header.Set("goa-view", view) + {{- end }} s.w.WriteHeader(http.StatusOK) + s.attempted = true }) - {{- if .Method.ViewedResult }} - {{- if .Method.ViewedResult.ViewName }} - res := {{ .Service.PkgName }}.{{ .Method.ViewedResult.Init.Name }}(v, {{ printf "%q" .Method.ViewedResult.ViewName }}) - {{- else }} - res := {{ .Service.PkgName }}.{{ .Method.ViewedResult.Init.Name }}(v, "default") - {{- end }} - {{- else }} - res := v - {{- end }} - {{ if .SSE.IDField }} - if id := res.{{ .SSE.IDField }}; id != "" { - fmt.Fprintf(s.w, "id: %s\n", id) + if id := {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.IDField }}; id != "" { + if _, err := fmt.Fprintf(s.w, "id: %s\n", id); err != nil { + return err + } } {{- end }} {{- if .SSE.EventField }} - if event := res.{{ .SSE.EventField }}; event != "" { - fmt.Fprintf(s.w, "event: %s\n", event) + if event := {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.EventField }}; event != "" { + if _, err := fmt.Fprintf(s.w, "event: %s\n", event); err != nil { + return err + } } {{- end }} {{- if .SSE.RetryField }} - if retry := res.{{ .SSE.RetryField }}; retry > 0 { - fmt.Fprintf(s.w, "retry: %d\n", retry) + if retry := {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.RetryField }}; {{ if .SSE.Retry.Pointer }}retry != nil && *{{ end }}retry > 0 { + if _, err := fmt.Fprintf(s.w, "retry: %d\n", {{ if .SSE.Retry.Pointer }}*{{ end }}retry); err != nil { + return err + } } {{- end }} - - var data string - var payload any - {{- if .SSE.HasResponseBody }} - body := New{{ goify .Method.Name true }}ResponseBody(res) - {{- if .SSE.DataField }} - payload = body.{{ .SSE.DataField }} - {{- else }} - payload = body - {{- end }} - {{- else }} - {{- if .SSE.DataField }} - payload = res.{{ .SSE.DataField }} - {{- else }} - payload = res - {{- end }} - {{- end }} - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { + {{- if .SSE.Data.Pointer }} + if hasData { + if _, err := fmt.Fprintf(s.w, "data: %s\n", data); err != nil { return err } - data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + if _, err := fmt.Fprintln(s.w); err != nil { + return err + } + {{- else }} + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err + } + {{- end }} - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -{{ comment "Close is a no-op for SSE. We keep the method for compatibility with other stream types." }} -func (s *{{ .SSE.StructName }}) Close() error { +{{- define "viewed_sse_server_body" }} + {{- $endpoint := .Endpoint }} + {{- with .Representation }} + body := {{ .ServerBody.Init.Declaration.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + {{- if $endpoint.SSE.DataField }} + {{ template "partial_sse_format" dict "Value" (printf "body.%s" $endpoint.SSE.DataField) "Encoding" $endpoint.SSE.Data }} + {{- else }} + {{ template "partial_sse_format" dict "Value" "body" "Encoding" $endpoint.SSE.Data }} + {{- end }} + {{- end }} +{{- end }} + +{{ comment "Close does nothing because an SSE stream closes with its HTTP response. The common stream interface still requires this method." }} +func (s *{{ .SSE.StructDeclaration.Name }}) Close() error { return nil } diff --git a/http/codegen/templates/server_start.go.tpl b/http/codegen/templates/server_start.go.tpl index f84053eab5..ef1a5e965f 100644 --- a/http/codegen/templates/server_start.go.tpl +++ b/http/codegen/templates/server_start.go.tpl @@ -1,3 +1,3 @@ {{ comment "handleHTTPServer starts configures and starts a HTTP server on the given URL. It shuts down the server if any error is received in the error channel." }} -func handleHTTPServer(ctx context.Context, u *url.URL{{ range $.Services }}{{ if .Service.Methods }}, {{ .Service.VarName }}Endpoints *{{ .Service.PkgName }}.Endpoints{{ end }}{{ end }}{{ range $.JSONRPCServices }}, {{ .Service.VarName }}Svc {{ .Service.PkgName }}.Service{{- $serviceName := .Service.Name }}{{- $found := false }}{{- range $.Services }}{{- if eq .Service.Name $serviceName }}{{- $found = true }}{{- break }}{{- end }}{{- end }}{{ if not $found }}, {{ .Service.VarName }}Endpoints *{{ .Service.PkgName }}.Endpoints{{ end }}{{ end }}, wg *sync.WaitGroup, errc chan error, dbg bool) {{ printf "{" }}{{ if not .JSONRPCServices }} +func handleHTTPServer(ctx context.Context, u *url.URL{{ range .HandlerArgs }}, {{ .Name }} {{ if .Pointer }}*{{ end }}{{ .PkgName }}.{{ .TypeName }}{{ end }}, wg *sync.WaitGroup, errc chan error, dbg bool) {{ printf "{" }}{{ if not .JSONRPCServices }} {{ end -}} diff --git a/http/codegen/templates/server_struct.go.tpl b/http/codegen/templates/server_struct.go.tpl index 3e56fa99d9..fd33726250 100644 --- a/http/codegen/templates/server_struct.go.tpl +++ b/http/codegen/templates/server_struct.go.tpl @@ -1,6 +1,6 @@ -{{ printf "%s lists the %s service endpoint HTTP handlers." .ServerStruct .Service.Name | comment }} -type {{ .ServerStruct }} struct { - Mounts []*{{ .MountPointStruct }} +{{ printf "%s lists the %s service endpoint HTTP handlers." .ServerStructDeclaration.Name .Service.Name | comment }} +type {{ .ServerStructDeclaration.Name }} struct { + Mounts []*{{ .MountPointStructDeclaration.Name }} {{- range .Endpoints }} {{ .Method.VarName }} http.Handler {{- end }} diff --git a/http/codegen/templates/server_type_init.go.tpl b/http/codegen/templates/server_type_init.go.tpl index 5c138ffa2a..bbc7ebe8c3 100644 --- a/http/codegen/templates/server_type_init.go.tpl +++ b/http/codegen/templates/server_type_init.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}({{- range .ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{- range .ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{- if .ServerCode }} {{ .ServerCode }} {{- if .ReturnTypeAttribute }} diff --git a/http/codegen/templates/server_use.go.tpl b/http/codegen/templates/server_use.go.tpl index 1f2d4df41b..a1146574c5 100644 --- a/http/codegen/templates/server_use.go.tpl +++ b/http/codegen/templates/server_use.go.tpl @@ -1,5 +1,5 @@ {{ printf "Use wraps the server handlers with the given middleware." | comment }} -func (s *{{ .ServerStruct }}) Use(m func(http.Handler) http.Handler) { +func (s *{{ .ServerStructDeclaration.Name }}) Use(m func(http.Handler) http.Handler) { {{- range .Endpoints }} s.{{ .Method.VarName }} = m(s.{{ .Method.VarName }}) {{- end }} diff --git a/http/codegen/templates/transform_helper.go.tpl b/http/codegen/templates/transform_helper.go.tpl index f240c92412..24aa5e8a64 100644 --- a/http/codegen/templates/transform_helper.go.tpl +++ b/http/codegen/templates/transform_helper.go.tpl @@ -1,5 +1,9 @@ -{{ printf "%s builds a value of type %s from a value of type %s." .Name .ResultTypeRef .ParamTypeRef | comment }} -func {{ .Name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { +{{- $name := .Name -}} +{{- if .Declaration -}} +{{- $name = .Declaration.Name -}} +{{- end }} +{{ printf "%s builds a value of type %s from a value of type %s." $name .ResultTypeRef .ParamTypeRef | comment }} +func {{ $name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { {{ .Code }} return res } diff --git a/http/codegen/templates/type_decl.go.tpl b/http/codegen/templates/type_decl.go.tpl index d51ad9e777..9ab7fceb83 100644 --- a/http/codegen/templates/type_decl.go.tpl +++ b/http/codegen/templates/type_decl.go.tpl @@ -1,2 +1,2 @@ {{ comment .Description }} -type {{ .VarName }} {{ .Def }} +type {{ .Declaration.Name }} {{ .Def }} diff --git a/http/codegen/templates/union_type.go.tpl b/http/codegen/templates/union_type.go.tpl index ed49a4d5d0..471b32328c 100644 --- a/http/codegen/templates/union_type.go.tpl +++ b/http/codegen/templates/union_type.go.tpl @@ -1,65 +1,65 @@ -{{- /* Union sum-type definition and helpers. */ -}} -// {{ .Name }} is a sum-type union. -type {{ .Name }} struct { - kind {{ .KindName }} +{{- /* Definition and helpers for a value that holds exactly one branch. */ -}} +// {{ .TypeDeclaration.Name }} holds exactly one of its branch values. +type {{ .TypeDeclaration.Name }} struct { + kind {{ .KindDeclaration.Name }} {{- range .Fields }} {{ .FieldName }} {{ .FieldType }} {{- end }} } -// {{ .KindName }} enumerates the union variants for {{ .Name }}. -type {{ .KindName }} string +// {{ .KindDeclaration.Name }} records which {{ .TypeDeclaration.Name }} branch is selected. +type {{ .KindDeclaration.Name }} string const ( {{- range .Fields }} - // {{ .KindConst }} identifies the {{ .Name }} branch of the union. - {{ .KindConst }} {{ $.KindName }} = "{{ .TypeTag }}" + // {{ .KindDeclaration.Name }} identifies the {{ .Name }} branch. + {{ .KindDeclaration.Name }} {{ $.KindDeclaration.Name }} = "{{ .TypeTag }}" {{- end }} ) -// Kind returns the discriminator value of the union. -func (u {{ .Name }}) Kind() {{ .KindName }} { +// Kind returns the selected branch. +func (u {{ .TypeDeclaration.Name }}) Kind() {{ .KindDeclaration.Name }} { return u.kind } {{- range .Fields }} -// New{{ $.Name }}{{ .FieldName }} constructs {{ $.Name }} with the {{ .Name }} branch set. -func New{{ $.Name }}{{ .FieldName }}(v {{ .FieldType }}) {{ $.Name }} { - return {{ $.Name }}{ - kind: {{ .KindConst }}, +// {{ .ConstructorDeclaration.Name }} constructs {{ $.TypeDeclaration.Name }} with the {{ .Name }} branch set. +func {{ .ConstructorDeclaration.Name }}(v {{ .FieldType }}) {{ $.TypeDeclaration.Name }} { + return {{ $.TypeDeclaration.Name }}{ + kind: {{ .KindDeclaration.Name }}, {{ .FieldName }}: v, } } -// As{{ .FieldName }} returns the value of the {{ .Name }} branch if set. -func (u {{ $.Name }}) As{{ .FieldName }}() (_ {{ .FieldType }}, ok bool) { - if u.kind != {{ .KindConst }} { +// As{{ .FieldName }} returns the value when the {{ .Name }} branch is selected. +func (u {{ $.TypeDeclaration.Name }}) As{{ .FieldName }}() (_ {{ .FieldType }}, ok bool) { + if u.kind != {{ .KindDeclaration.Name }} { return } return u.{{ .FieldName }}, true } -// Set{{ .FieldName }} sets the {{ .Name }} branch of the union. -func (u *{{ $.Name }}) Set{{ .FieldName }}(v {{ .FieldType }}) { - u.kind = {{ .KindConst }} +// Set{{ .FieldName }} selects the {{ .Name }} branch and stores v. +func (u *{{ $.TypeDeclaration.Name }}) Set{{ .FieldName }}(v {{ .FieldType }}) { + u.kind = {{ .KindDeclaration.Name }} u.{{ .FieldName }} = v } {{- end }} -// Validate ensures the union discriminant is valid. -func (u {{ .Name }}) Validate() error { +// Validate ensures exactly one valid branch is selected. +func (u {{ .TypeDeclaration.Name }}) Validate() error { switch u.kind { case "": return goa.InvalidEnumValueError({{ printf "%q" .TypeKey }}, "", []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) {{- range .Fields }} - case {{ .KindConst }}: + case {{ .KindDeclaration.Name }}: {{- if .Nilable }} if u.{{ .FieldName }} == nil { - return goa.MissingFieldError({{ printf "%q" $.ValueKey }}, "{{ $.Name }}") + return goa.MissingFieldError({{ printf "%q" $.ValueKey }}, "{{ $.TypeDeclaration.Name }}") } {{- end }} return nil @@ -67,14 +67,14 @@ func (u {{ .Name }}) Validate() error { default: return goa.InvalidEnumValueError({{ printf "%q" $.TypeKey }}, u.kind, []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) } } // MarshalJSON marshals the union into the canonical {type,value} JSON shape. -func (u {{ .Name }}) MarshalJSON() ([]byte, error) { +func (u {{ .TypeDeclaration.Name }}) MarshalJSON() ([]byte, error) { if err := u.Validate(); err != nil { return nil, err } @@ -83,11 +83,11 @@ func (u {{ .Name }}) MarshalJSON() ([]byte, error) { ) switch u.kind { {{- range .Fields }} - case {{ .KindConst }}: + case {{ .KindDeclaration.Name }}: value = u.{{ .FieldName }} {{- end }} default: - return nil, fmt.Errorf("unexpected {{ .Name }} discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected {{ .TypeDeclaration.Name }} kind %q", u.kind) } return json.Marshal(struct { Type string {{ printf "`json:\"%s\"`" .TypeKey }} @@ -99,7 +99,7 @@ func (u {{ .Name }}) MarshalJSON() ([]byte, error) { } // UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape. -func (u *{{ .Name }}) UnmarshalJSON(data []byte) error { +func (u *{{ .TypeDeclaration.Name }}) UnmarshalJSON(data []byte) error { var raw struct { Type string {{ printf "`json:\"%s\"`" .TypeKey }} Value json.RawMessage {{ printf "`json:\"%s\"`" .ValueKey }} @@ -108,28 +108,28 @@ func (u *{{ .Name }}) UnmarshalJSON(data []byte) error { return err } if len(raw.Value) == 0 { - return goa.MissingFieldError({{ printf "%q" .ValueKey }}, "{{ .Name }}") + return goa.MissingFieldError({{ printf "%q" .ValueKey }}, "{{ .TypeDeclaration.Name }}") } if bytes.Equal(bytes.TrimSpace(raw.Value), []byte("null")) { return goa.InvalidFieldTypeError({{ printf "%q" .ValueKey }}, nil, "non-null JSON value") } switch raw.Type { {{- range .Fields }} - case string({{ .KindConst }}): + case string({{ .KindDeclaration.Name }}): var v {{ .FieldType }} if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = {{ .KindConst }} + u.kind = {{ .KindDeclaration.Name }} u.{{ .FieldName }} = v {{- end }} default: if raw.Type == "" { - return goa.MissingFieldError({{ printf "%q" .TypeKey }}, "{{ .Name }}") + return goa.MissingFieldError({{ printf "%q" .TypeKey }}, "{{ .TypeDeclaration.Name }}") } return goa.InvalidEnumValueError({{ printf "%q" .TypeKey }}, raw.Type, []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) } diff --git a/http/codegen/templates/validate.go.tpl b/http/codegen/templates/validate.go.tpl index 115410fef4..70bf1959f9 100644 --- a/http/codegen/templates/validate.go.tpl +++ b/http/codegen/templates/validate.go.tpl @@ -1,5 +1,13 @@ -{{ printf "Validate%s runs the validations defined on %s" .VarName .Name | comment }} -func Validate{{ .VarName }}(body {{ .Ref }}) (err error) { +{{ printf "%s runs the validations defined on %s" .ValidatorDeclaration.Name .Name | comment }} +func {{ .ValidatorDeclaration.Name }}(body {{ .Ref }}) (err error) { {{ .ValidateDef }} - return + return } + +{{- if .NestedValidatorDeclaration }} +{{ printf "%s checks %s and reports errors using the path supplied by its caller" .NestedValidatorDeclaration.Name .Name | comment }} +func {{ .NestedValidatorDeclaration.Name }}(body {{ .Ref }}, path string) (err error) { + {{ .NestedValidateDef }} + return +} +{{- end }} diff --git a/http/codegen/templates/websocket_close.go.tpl b/http/codegen/templates/websocket_close.go.tpl index c7e832ac80..fa8c1d4f8b 100644 --- a/http/codegen/templates/websocket_close.go.tpl +++ b/http/codegen/templates/websocket_close.go.tpl @@ -1,10 +1,29 @@ {{ printf "Close closes the %q endpoint websocket connection." .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) Close() error { - var err error +func (s *{{ .VarDeclaration.Name }}) Close() error { {{- if eq .Type "server" }} - if s.conn == nil { - return nil + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +{{ comment "close opens the websocket connection when needed, sends its normal close message, and closes it." }} +func (s *{{ .VarDeclaration.Name }}) close() error { + var err error + {{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} + view := s.view + if view == "" { + view = "default" + } + switch view { + {{- range .Endpoint.Method.ViewedResult.Views }} + case {{ printf "%q" .Name }}: + {{- end }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .Endpoint.Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} }) } + {{- end }} + {{- template "partial_websocket_upgrade" (upgradeParams .Endpoint "Close") }} if err = s.conn.WriteControl( websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "server closing connection"), @@ -12,11 +31,13 @@ func (s *{{ .VarName }}) Close() error { ); err != nil { return err } + return s.conn.Close() {{- else }} {{/* client side code */}} + var err error {{ comment "Send a nil payload to the server implying client closing connection." }} if err = s.conn.WriteJSON(nil); err != nil { return err } -{{- end }} return s.conn.Close() +{{- end }} } diff --git a/http/codegen/templates/websocket_conn_configurer_struct.go.tpl b/http/codegen/templates/websocket_conn_configurer_struct.go.tpl index 2a4947699d..588001b35f 100644 --- a/http/codegen/templates/websocket_conn_configurer_struct.go.tpl +++ b/http/codegen/templates/websocket_conn_configurer_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "ConnConfigurer holds the websocket connection configurer functions for the streaming endpoints in %q service." .Service.Name | comment }} -type ConnConfigurer struct { +{{ printf "%s holds the websocket connection configurer functions for the streaming endpoints in %q service." .Declaration.Name .Service.Name | comment }} +type {{ .Declaration.Name }} struct { {{- range .Endpoints }} {{- if isWebSocketEndpoint . }} {{ .Method.VarName }}Fn goahttp.ConnConfigureFunc diff --git a/http/codegen/templates/websocket_conn_configurer_struct_init.go.tpl b/http/codegen/templates/websocket_conn_configurer_struct_init.go.tpl index 9a7eb29382..69207e866f 100644 --- a/http/codegen/templates/websocket_conn_configurer_struct_init.go.tpl +++ b/http/codegen/templates/websocket_conn_configurer_struct_init.go.tpl @@ -1,6 +1,6 @@ -{{ printf "NewConnConfigurer initializes the websocket connection configurer function with fn for all the streaming endpoints in %q service." .Service.Name | comment }} -func NewConnConfigurer(fn goahttp.ConnConfigureFunc) *ConnConfigurer { - return &ConnConfigurer{ +{{ printf "%s initializes the websocket connection configurer function with fn for all the streaming endpoints in %q service." .InitDeclaration.Name .Service.Name | comment }} +func {{ .InitDeclaration.Name }}(fn goahttp.ConnConfigureFunc) *{{ .Declaration.Name }} { + return &{{ .Declaration.Name }}{ {{- range .Endpoints }} {{- if isWebSocketEndpoint . }} {{ .Method.VarName}}Fn: fn, diff --git a/http/codegen/templates/websocket_recv.go.tpl b/http/codegen/templates/websocket_recv.go.tpl index b5056a38e3..2298a7e285 100644 --- a/http/codegen/templates/websocket_recv.go.tpl +++ b/http/codegen/templates/websocket_recv.go.tpl @@ -1,15 +1,15 @@ {{ comment .RecvDesc }} -func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { +func (s *{{ .VarDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { var ( rv {{ .RecvTypeRef }} {{- if eq .Type "server" }} {{- if .RecvTypeIsPointer }} - body {{ .Payload.VarName }} + body {{ if .Payload.Declaration }}{{ .Payload.Declaration.Name }}{{ else }}{{ .Payload.VarName }}{{ end }} {{- else }} - msg *{{ .Payload.VarName }} + msg *{{ if .Payload.Declaration }}{{ .Payload.Declaration.Name }}{{ else }}{{ .Payload.VarName }}{{ end }} {{- end }} {{- else }} - body {{ .Response.ClientBody.VarName }} + body {{ if .Response.ClientBody.Declaration }}{{ .Response.ClientBody.Declaration.Name }}{{ else }}{{ .Response.ClientBody.VarName }}{{ end }} {{- end }} err error ) @@ -29,22 +29,26 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { {{- end }} return rv, io.EOF } - {{- if .Payload.ValidateRef }} + {{- if or (and .Payload.ValidatorDeclaration .Payload.ValidationTarget) .Payload.ValidateRef }} {{- if not .RecvTypeIsPointer }} body := *msg {{- end }} - {{ .Payload.ValidateRef }} + {{- if and .Payload.ValidatorDeclaration .Payload.ValidationTarget }} + err = {{ .Payload.ValidatorDeclaration.Name }}({{ .Payload.ValidationTarget }}) + {{- else }} + {{ .Payload.ValidateRef }} + {{- end }} if err != nil { return rv, err } {{- end }} {{- if .Payload.Init }} - return {{ .Payload.Init.Name }}({{ if .RecvTypeIsPointer }}body{{ else }}msg{{ end }}), nil + return {{ .Payload.Init.Declaration.Name }}({{ if .RecvTypeIsPointer }}body{{ else }}msg{{ end }}), nil {{- else }} return {{ if .RecvTypeIsPointer }}body{{ else }}*msg{{ end }}, nil {{- end }} {{- else }} {{/* client side code */}} - {{- if eq .RecvName "CloseAndRecv" }} + {{- if isClientStreamKind .Kind }} defer s.conn.Close() {{ comment "Send a nil payload to the server implying end of message" }} if err = s.conn.WriteJSON(nil); err != nil { @@ -61,20 +65,24 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { if err != nil { return rv, err } - {{- if and .Response.ClientBody.ValidateRef (not .Endpoint.Method.ViewedResult) }} + {{- if and (or (and .Response.ClientBody.ValidatorDeclaration .Response.ClientBody.ValidationTarget) .Response.ClientBody.ValidateRef) (not .Endpoint.Method.ViewedResult) }} + {{- if and .Response.ClientBody.ValidatorDeclaration .Response.ClientBody.ValidationTarget }} + err = {{ .Response.ClientBody.ValidatorDeclaration.Name }}({{ .Response.ClientBody.ValidationTarget }}) + {{- else }} {{ .Response.ClientBody.ValidateRef }} + {{- end }} if err != nil { return rv, err } {{- end }} {{- if .Response.ResultInit }} - res := {{ .Response.ResultInit.Name }}({{ range .Response.ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + res := {{ .Response.ResultInit.Declaration.Name }}({{ range .Response.ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- if .Endpoint.Method.ViewedResult }}{{ with .Endpoint.Method.ViewedResult }} vres := {{ if not .IsCollection }}&{{ end }}{{ .ViewsPkg }}.{{ .VarName }}{Projected: res, View: {{ if .ViewName }}{{ printf "%q" .ViewName }}{{ else }}s.view{{ end }} } - if err := {{ .ViewsPkg }}.Validate{{ $.Endpoint.Method.Result }}(vres); err != nil { + if err := {{ .ViewsPkg }}.{{ .Validate.Declaration.Name }}(vres); err != nil { return rv, goahttp.ErrValidationError("{{ $.Endpoint.ServiceName }}", "{{ $.Endpoint.Method.Name }}", err) } - return {{ $.PkgName }}.{{ .ResultInit.Name }}(vres){{ end }}, nil + return {{ $.PkgName }}.{{ .ResultInit.Declaration.Name }}(vres){{ end }}, nil {{- else }} return res, nil {{- end }} @@ -85,6 +93,6 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { } {{ comment .RecvWithContextDesc }} -func (s *{{ .VarName }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { +func (s *{{ .VarDeclaration.Name }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { return s.{{ .RecvName }}() } diff --git a/http/codegen/templates/websocket_send.go.tpl b/http/codegen/templates/websocket_send.go.tpl index 11f2f02b22..5cbf7ce92d 100644 --- a/http/codegen/templates/websocket_send.go.tpl +++ b/http/codegen/templates/websocket_send.go.tpl @@ -1,17 +1,40 @@ +{{/* +websocket_send.go.tpl writes one service result to a WebSocket. A method with +several views keeps only the caller's view choice in generated code. +*/ -}} {{ comment .SendDesc }} -func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { +func (s *{{ .VarDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { {{- if eq .Type "server" }} - {{- if eq .SendName "Send" }} + {{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + switch view { + {{- range .Endpoint.Method.ViewedResult.Views }} + case {{ printf "%q" .Name }}: + {{- end }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .Endpoint.Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} }) + } + {{- end }} + {{- if not (isClientStreamKind .Kind) }} var err error {{- template "partial_websocket_upgrade" (upgradeParams .Endpoint .SendName) }} - {{- else }} {{/* SendAndClose */}} + {{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} + if s.sentView == "" { + s.sentView = view + } + {{- end }} + {{- else }} defer s.conn.Close() {{- end }} {{- if .Endpoint.Method.ViewedResult }} {{- if .Endpoint.Method.ViewedResult.ViewName }} - res := {{ .PkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Name }}(v, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) - {{- else }} - res := {{ .PkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Name }}(v, s.view) + res := {{ .PkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(v, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) {{- end }} {{- else }} res := v @@ -22,21 +45,25 @@ func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { {{- if .Endpoint.Method.ViewedResult }} {{- if .Endpoint.Method.ViewedResult.ViewName }} {{- $vsb := (viewedServerBody $.Response.ServerBody .Endpoint.Method.ViewedResult.ViewName) }} - body := {{ $vsb.Init.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body := {{ $vsb.Init.Declaration.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- else }} - var body any - switch s.view { + switch view { {{- range .Endpoint.Method.ViewedResult.Views }} case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: + res := {{ $.PkgName }}.{{ $.Endpoint.Method.ViewedResult.Init.Declaration.Name }}(v, {{ printf "%q" .Name }}) {{- $vsb := (viewedServerBody $.Response.ServerBody .Name) }} - body = {{ $vsb.Init.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + return s.conn.WriteJSON({{ $vsb.Init.Declaration.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }})) {{- end }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .Endpoint.Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} }) } {{- end }} {{- else }} - body := {{ (index .Response.ServerBody 0).Init.Name }}({{ range (index .Response.ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body := {{ (index .Response.ServerBody 0).Init.Declaration.Name }}({{ range (index .Response.ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- end }} + {{- if or (not .Endpoint.Method.ViewedResult) .Endpoint.Method.ViewedResult.ViewName }} return s.conn.WriteJSON(body) + {{- end }} {{- else }} return s.conn.WriteJSON(res) {{- end }} @@ -45,7 +72,7 @@ func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { {{- end }} {{- else }} {{- if .Payload.Init }} - body := {{ .Payload.Init.Name }}(v) + body := {{ .Payload.Init.Declaration.Name }}(v) return s.conn.WriteJSON(body) {{- else }} return s.conn.WriteJSON(v) @@ -54,6 +81,6 @@ func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { } {{ comment .SendWithContextDesc }} -func (s *{{ .VarName }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { +func (s *{{ .VarDeclaration.Name }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { return s.{{ .SendName }}(v) } diff --git a/http/codegen/templates/websocket_set_view.go.tpl b/http/codegen/templates/websocket_set_view.go.tpl index b8e44d61ff..e9ed779835 100644 --- a/http/codegen/templates/websocket_set_view.go.tpl +++ b/http/codegen/templates/websocket_set_view.go.tpl @@ -1,4 +1,4 @@ {{ printf "SetView sets the view to render the %s type before sending to the %q endpoint websocket connection." .SendTypeName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) SetView(view string) { +func (s *{{ .VarDeclaration.Name }}) SetView(view string) { s.view = view } diff --git a/http/codegen/templates/websocket_struct_type.go.tpl b/http/codegen/templates/websocket_struct_type.go.tpl index a3ed5d915d..2512b925cf 100644 --- a/http/codegen/templates/websocket_struct_type.go.tpl +++ b/http/codegen/templates/websocket_struct_type.go.tpl @@ -1,9 +1,15 @@ -{{ printf "%s implements the %s interface." .VarName .Interface | comment }} -type {{ .VarName }} struct { +{{ printf "%s implements the %s interface." .VarDeclaration.Name .Interface | comment }} +type {{ .VarDeclaration.Name }} struct { {{- if eq .Type "server" }} once sync.Once {{ comment "upgradeErr is the error returned by the websocket upgrade attempt." }} upgradeErr error + {{- if .MustClose }} + {{ comment "closeOnce makes repeated Close calls return the first close result without writing again." }} + closeOnce sync.Once + {{ comment "closeErr is the result of the first Close call." }} + closeErr error + {{- end }} {{ comment "upgrader is the websocket connection upgrader." }} upgrader goahttp.Upgrader {{ comment "configurer is the websocket connection configurer." }} @@ -21,6 +27,10 @@ type {{ .VarName }} struct { {{- if not .Endpoint.Method.ViewedResult.ViewName }} {{ printf "view is the view to render %s result type before sending to the websocket connection." .SendTypeName | comment }} view string + {{- if eq .Type "server" }} + {{ comment "sentView is the result view named during the WebSocket upgrade. Later sends must use the same view." }} + sentView string + {{- end }} {{- end }} {{- end }} } diff --git a/http/codegen/testdata/error_response_dsls.go b/http/codegen/testdata/error_response_dsls.go index b43c63add4..b7ffd59f2a 100644 --- a/http/codegen/testdata/error_response_dsls.go +++ b/http/codegen/testdata/error_response_dsls.go @@ -1,3 +1,5 @@ +// This file defines HTTP error response designs used by transport codegen +// tests, including reusable API mappings and service-level error contracts. package testdata import ( @@ -148,7 +150,7 @@ var APINoBodyErrorResponseDSL = func() { }) }) Service("ServiceNoBodyErrorResponse", func() { - Error("bad_request") + Error("bad_request", StringError) Method("MethodServiceErrorResponse", func() { HTTP(func() { GET("/one/two") @@ -171,7 +173,7 @@ var APINoBodyErrorResponseWithContentTypeDSL = func() { }) }) Service("ServiceNoBodyErrorResponse", func() { - Error("bad_request") + Error("bad_request", StringError) Method("MethodServiceErrorResponse", func() { HTTP(func() { GET("/one/two") @@ -232,6 +234,19 @@ var ErrorExamplesDSL = func() { var _ = Service("Errors", func() { Method("Error", func() { Error("not_found") // default example + Error("retry", func() { + Temporary() + }) + Error("deadline", func() { + Timeout() + }) + Error("retry_deadline", func() { + Temporary() + Timeout() + }) + Error("internal", func() { + Fault() + }) Error("bad_request", func() { Example("BadRequest example", func() { Value(Val{ @@ -248,6 +263,10 @@ var ErrorExamplesDSL = func() { HTTP(func() { GET("/") Response("not_found", StatusNotFound) + Response("retry", StatusTooManyRequests) + Response("deadline", StatusGatewayTimeout) + Response("retry_deadline", StatusServiceUnavailable) + Response("internal", StatusInternalServerError) Response("bad_request", StatusBadRequest) Response("custom", StatusConflict) }) diff --git a/http/codegen/testdata/golden/client-mixed-results.golden b/http/codegen/testdata/golden/client-mixed-results.golden new file mode 100644 index 0000000000..6276078067 --- /dev/null +++ b/http/codegen/testdata/golden/client-mixed-results.golden @@ -0,0 +1,36 @@ +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { + var ( + doer goahttp.Doer + ) + { + doer = &http.Client{Timeout: time.Duration(timeout) * time.Second} + if debug { + doer = goahttp.NewDebugDoer(doer) + } + } + + endpoint, payload, err := cli.ParseEndpoint( + scheme, + host, + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + debug, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "mixed-results-service": + switch flag.Arg(1) { + case "create": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") +} + +func httpUsageExamples() string { + return cli.UsageExamples() +} diff --git a/http/codegen/testdata/golden/client-no-server.golden b/http/codegen/testdata/golden/client-no-server.golden index 91381eb4ed..05bac95ee1 100644 --- a/http/codegen/testdata/golden/client-no-server.golden +++ b/http/codegen/testdata/golden/client-no-server.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -18,13 +18,17 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er debug, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden b/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden index 91381eb4ed..a820b824e2 100644 --- a/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden +++ b/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -18,13 +18,22 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er debug, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "another-service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client-server-hosting-service-subset.golden b/http/codegen/testdata/golden/client-server-hosting-service-subset.golden index 91381eb4ed..05bac95ee1 100644 --- a/http/codegen/testdata/golden/client-server-hosting-service-subset.golden +++ b/http/codegen/testdata/golden/client-server-hosting-service-subset.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -18,13 +18,17 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er debug, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client-streaming-input-only.golden b/http/codegen/testdata/golden/client-streaming-input-only.golden new file mode 100644 index 0000000000..2e37ed9912 --- /dev/null +++ b/http/codegen/testdata/golden/client-streaming-input-only.golden @@ -0,0 +1,45 @@ +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { + var ( + doer goahttp.Doer + ) + { + doer = &http.Client{Timeout: time.Duration(timeout) * time.Second} + if debug { + doer = goahttp.NewDebugDoer(doer) + } + } + + var ( + dialer *websocket.Dialer + ) + { + dialer = websocket.DefaultDialer + } + + switch flag.Arg(0) { + case "streaming-payload-service": + switch flag.Arg(1) { + case "streaming-payload-method": + return errors.New("example client does not support streamed input for service \"StreamingPayloadService\" method \"StreamingPayloadMethod\"") + } + } + _, _, err := cli.ParseEndpoint( + scheme, + host, + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + debug, + dialer, + nil, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + panic("parsed HTTP command has no generated result writer") +} + +func httpUsageExamples() string { + return cli.UsageExamples() +} diff --git a/http/codegen/testdata/golden/client-streaming-multiple-services.golden b/http/codegen/testdata/golden/client-streaming-multiple-services.golden index 3d8ce10318..ba65d0dc66 100644 --- a/http/codegen/testdata/golden/client-streaming-multiple-services.golden +++ b/http/codegen/testdata/golden/client-streaming-multiple-services.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -16,6 +16,13 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er dialer = websocket.DefaultDialer } + switch flag.Arg(0) { + case "streaming-service-b": + switch flag.Arg(1) { + case "method": + return errors.New("example client does not support streamed input for service \"StreamingServiceB\" method \"Method\"") + } + } endpoint, payload, err := cli.ParseEndpoint( scheme, host, @@ -28,13 +35,22 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er nil, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "streaming-service-a": + switch flag.Arg(1) { + case "method": + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.(streamingservicea.MethodClientStream) + return writeStreamResults(ctx, stdout, stream.RecvWithContext) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client-streaming.golden b/http/codegen/testdata/golden/client-streaming.golden index fbd2b88761..995491ae9c 100644 --- a/http/codegen/testdata/golden/client-streaming.golden +++ b/http/codegen/testdata/golden/client-streaming.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -27,13 +27,22 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er nil, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "streaming-result-service": + switch flag.Arg(1) { + case "streaming-result-method": + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.(streamingresultservice.StreamingResultMethodClientStream) + return writeStreamResults(ctx, stdout, stream.RecvWithContext) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden b/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden index 4dbe3fd908..5d2a771238 100644 --- a/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_body-user-inner.go.golden @@ -4,7 +4,7 @@ func NewMethodBodyUserInnerRequestBody(p *servicebodyuserinner.PayloadType) *MethodBodyUserInnerRequestBody { body := &MethodBodyUserInnerRequestBody{} if p.Inner != nil { - body.Inner = marshalServicebodyuserinnerInnerTypeToInnerTypeRequestBody(p.Inner) + body.Inner = marshalServicebodyuserinnerInnerTypeToInnerTypeRequestBodyOptional(p.Inner) } return body } diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden index f612beb92d..8751c3da3f 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object-views.go.golden @@ -5,7 +5,7 @@ func NewMethodExplicitBodyUserResultObjectMultipleViewResulttypemultipleviewsOK(body *MethodExplicitBodyUserResultObjectMultipleViewResponseBody, c *string) *serviceexplicitbodyuserresultobjectmultipleviewviews.ResulttypemultipleviewsView { v := &serviceexplicitbodyuserresultobjectmultipleviewviews.ResulttypemultipleviewsView{} if body.A != nil { - v.A = unmarshalUserTypeResponseBodyToServiceexplicitbodyuserresultobjectmultipleviewviewsUserTypeView(body.A) + v.A = unmarshalUserTypeResponseBodyToServiceexplicitbodyuserresultobjectmultipleviewviewsUserTypeViewOptional(body.A) } v.C = c diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden index 136d4adc83..163571450e 100644 --- a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-object.go.golden @@ -5,7 +5,7 @@ func NewMethodExplicitBodyUserResultObjectResulttypeOK(body *MethodExplicitBodyUserResultObjectResponseBody, c *string, b *string) *serviceexplicitbodyuserresultobjectviews.ResulttypeView { v := &serviceexplicitbodyuserresultobjectviews.ResulttypeView{} if body.A != nil { - v.A = unmarshalUserTypeResponseBodyToServiceexplicitbodyuserresultobjectviewsUserTypeView(body.A) + v.A = unmarshalUserTypeResponseBodyToServiceexplicitbodyuserresultobjectviewsUserTypeViewOptional(body.A) } v.C = c v.B = b diff --git a/http/codegen/testdata/golden/client_cli_body-custom-name.go.golden b/http/codegen/testdata/golden/client_cli_body-custom-name.go.golden index a520c7dfae..f9cd37c2e4 100644 --- a/http/codegen/testdata/golden/client_cli_body-custom-name.go.golden +++ b/http/codegen/testdata/golden/client_cli_body-custom-name.go.golden @@ -6,7 +6,7 @@ func BuildMethodBodyCustomNamePayload(serviceBodyCustomNameMethodBodyCustomNameB { err = json.Unmarshal([]byte(serviceBodyCustomNameMethodBodyCustomNameBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"b\": \"Itaque ab itaque.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"b\": \"Et consequatur molestiae.\"\n }'") } } v := &servicebodycustomname.MethodBodyCustomNamePayload{ diff --git a/http/codegen/testdata/golden/client_cli_body-query-path-object-build.go.golden b/http/codegen/testdata/golden/client_cli_body-query-path-object-build.go.golden index 0e95662003..eb03207236 100644 --- a/http/codegen/testdata/golden/client_cli_body-query-path-object-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_body-query-path-object-build.go.golden @@ -6,7 +6,7 @@ func BuildMethodBodyQueryPathObjectPayload(serviceBodyQueryPathObjectMethodBodyQ { err = json.Unmarshal([]byte(serviceBodyQueryPathObjectMethodBodyQueryPathObjectBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Hic at eveniet porro sit nisi.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Dolor rerum.\"\n }'") } } var c2 string diff --git a/http/codegen/testdata/golden/client_cli_empty-body-build.go.golden b/http/codegen/testdata/golden/client_cli_empty-body-build.go.golden index 68335349e1..995004eae1 100644 --- a/http/codegen/testdata/golden/client_cli_empty-body-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_empty-body-build.go.golden @@ -8,7 +8,7 @@ func BuildMethodBodyPrimitiveArrayUserPayload(serviceBodyPrimitiveArrayUserMetho if serviceBodyPrimitiveArrayUserMethodBodyPrimitiveArrayUserA != "" { err = json.Unmarshal([]byte(serviceBodyPrimitiveArrayUserMethodBodyPrimitiveArrayUserA), &a) if err != nil { - return nil, fmt.Errorf("invalid JSON for a, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"Ducimus hic sint temporibus velit blanditiis in.\",\n \"Sint qui sit quaerat quas illo.\"\n ]'") + return nil, fmt.Errorf("invalid JSON for a, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"Enim ut qui quaerat assumenda voluptatum.\",\n \"Ut iste molestiae.\"\n ]'") } } } diff --git a/http/codegen/testdata/golden/client_cli_map-query-object.go.golden b/http/codegen/testdata/golden/client_cli_map-query-object.go.golden index ff5cf7ad44..6e3accf9e9 100644 --- a/http/codegen/testdata/golden/client_cli_map-query-object.go.golden +++ b/http/codegen/testdata/golden/client_cli_map-query-object.go.golden @@ -27,7 +27,7 @@ func BuildMethodMapQueryObjectPayload(serviceMapQueryObjectMethodMapQueryObjectB { err = json.Unmarshal([]byte(serviceMapQueryObjectMethodMapQueryObjectC), &c) if err != nil { - return nil, fmt.Errorf("invalid JSON for c, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"5691875720969669573\": [\n \"Rerum quia ea.\",\n \"Qui iure hic quisquam nulla error.\",\n \"Nihil necessitatibus expedita architecto atque.\",\n \"Nulla nisi.\"\n ],\n \"7072545540989245598\": [\n \"Adipisci veritatis sunt impedit et soluta fugiat.\",\n \"Ut commodi cum exercitationem voluptas autem voluptates.\",\n \"Debitis voluptatem.\"\n ],\n \"986504572350809452\": [\n \"Accusantium corrupti sed enim optio consequatur aut.\",\n \"Est molestiae qui.\"\n ]\n }'") + return nil, fmt.Errorf("invalid JSON for c, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"1025921908706477149\": [\n \"Nostrum et.\",\n \"Reiciendis rerum aliquam vitae esse.\",\n \"Omnis alias cumque nulla rerum.\"\n ]\n }'") } } v := &servicemapqueryobject.PayloadType{ diff --git a/http/codegen/testdata/golden/client_cli_map-query.go.golden b/http/codegen/testdata/golden/client_cli_map-query.go.golden index f9367d6765..042a59c994 100644 --- a/http/codegen/testdata/golden/client_cli_map-query.go.golden +++ b/http/codegen/testdata/golden/client_cli_map-query.go.golden @@ -85,7 +85,7 @@ func ParseEndpoint( err = json.Unmarshal([]byte(*serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag), &val) data = val if err != nil { - return nil, nil, fmt.Errorf("invalid JSON for serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"Quidem accusamus.\": [\n 3906106080549376696,\n 18039872150139340048\n ],\n \"Repellat occaecati officiis doloremque.\": [\n 8210943955859777434,\n 7701234893751856770,\n 12687845642945213165,\n 5080739739723289788\n ],\n \"Voluptas officiis in eum nostrum voluptatem.\": [\n 7799018251876253497,\n 23602025540978920\n ]\n }'") + return nil, nil, fmt.Errorf("invalid JSON for serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"Deserunt itaque pariatur ipsam consequuntur nostrum.\": [\n 11990920063555956079,\n 17089161724625472560\n ],\n \"Libero dolore est accusantium explicabo nostrum rerum.\": [\n 2438159965416078706,\n 977176377124085955,\n 16802288397808742928\n ],\n \"Occaecati nulla iusto.\": [\n 11103002557489322756,\n 17383528869145716448,\n 5505226717552383690,\n 3219217238434884659\n ]\n }'") } } } diff --git a/http/codegen/testdata/golden/client_cli_multi-build.go.golden b/http/codegen/testdata/golden/client_cli_multi-build.go.golden index 9675487ba0..1279db874d 100644 --- a/http/codegen/testdata/golden/client_cli_multi-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_multi-build.go.golden @@ -6,7 +6,7 @@ func BuildMethodMultiPayloadPayload(serviceMultiMethodMultiPayloadBody string, s { err = json.Unmarshal([]byte(serviceMultiMethodMultiPayloadBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"c\": {\n \"att\": true,\n \"att10\": \"Aut et.\",\n \"att11\": \"SGljIHZpdGFlIHZlbGl0IG1hZ25hbSBhdC4=\",\n \"att12\": \"Voluptatibus harum autem totam quaerat quis ut.\",\n \"att13\": [\n \"Sunt inventore est voluptatum ipsam omnis.\",\n \"Quidem quo a non sequi quo.\",\n \"Voluptate id corrupti.\",\n \"Sit cum quia magnam est nihil illo.\"\n ],\n \"att14\": {\n \"Fuga consequatur magnam vel sint doloribus.\": \"Corporis deserunt.\",\n \"Omnis eveniet.\": \"Nobis quia incidunt nemo illum.\"\n },\n \"att15\": {\n \"inline\": \"Suscipit voluptate magnam facilis.\"\n },\n \"att2\": 1586082787030249061,\n \"att3\": 1689516036,\n \"att4\": 2650159337126663108,\n \"att5\": 15366113216962746133,\n \"att6\": 2615133795,\n \"att7\": 13370278622483873840,\n \"att8\": 0.7927184,\n \"att9\": 0.12075756206016808\n }\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"c\": {\n \"att\": false,\n \"att10\": \"Fugiat deserunt qui unde odit blanditiis aut.\",\n \"att11\": \"SXBzYSBtaW51cyBpdXJlIHZlcml0YXRpcyByZXJ1bS4=\",\n \"att12\": \"Illum nulla alias.\",\n \"att13\": [\n \"Quod quos architecto.\",\n \"Deleniti numquam.\",\n \"Similique voluptatibus non quaerat eum nobis.\"\n ],\n \"att14\": {\n \"Libero laboriosam.\": \"Et nesciunt corrupti.\"\n },\n \"att15\": {\n \"inline\": \"Labore hic unde.\"\n },\n \"att2\": 7364427417869607579,\n \"att3\": 1230775405,\n \"att4\": 7548751036052687007,\n \"att5\": 13421568780903955996,\n \"att6\": 955870894,\n \"att7\": 5943663396465067570,\n \"att8\": 0.45085564,\n \"att9\": 0.9928171957403447\n }\n }'") } } var b *string @@ -28,7 +28,7 @@ func BuildMethodMultiPayloadPayload(serviceMultiMethodMultiPayloadBody string, s } v := &servicemulti.MethodMultiPayloadPayload{} if body.C != nil { - v.C = marshalUserTypeRequestBodyToServicemultiUserType(body.C) + v.C = marshalUserTypeRequestBodyToServicemultiUserTypeOptional(body.C) } v.B = b v.A = a diff --git a/http/codegen/testdata/golden/client_cli_param-validation-build.go.golden b/http/codegen/testdata/golden/client_cli_param-validation-build.go.golden index bcb2801b73..5ee5c870fb 100644 --- a/http/codegen/testdata/golden/client_cli_param-validation-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_param-validation-build.go.golden @@ -12,8 +12,8 @@ func BuildMethodParamValidatePayload(serviceParamValidateMethodParamValidateA st if err != nil { return nil, fmt.Errorf("invalid value for a, must be INT") } - if *a < 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("a", *a, 1, true)) + if val < 1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("a", val, 1, true)) } if err != nil { return nil, err diff --git a/http/codegen/testdata/golden/client_cli_payload-array-primitive-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-array-primitive-type.go.golden index 78b13409ba..f333133bb6 100644 --- a/http/codegen/testdata/golden/client_cli_payload-array-primitive-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-array-primitive-type.go.golden @@ -85,7 +85,7 @@ func ParseEndpoint( err = json.Unmarshal([]byte(*serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag), &val) data = val if err != nil { - return nil, nil, fmt.Errorf("invalid JSON for serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"val\",\n \"val\",\n \"val\"\n ]'") + return nil, nil, fmt.Errorf("invalid JSON for serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"val\"\n ]'") } } } diff --git a/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden index 461f40f436..358794350b 100644 --- a/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden @@ -6,7 +6,7 @@ func BuildMethodBodyInlineArrayUserPayload(serviceBodyInlineArrayUserMethodBodyI { err = json.Unmarshal([]byte(serviceBodyInlineArrayUserMethodBodyInlineArrayUserBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n },\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n }\n ]'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n },\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n },\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n }\n ]'") } } v := make([]*servicebodyinlinearrayuser.ElemType, len(body)) diff --git a/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden index 4d25a84bfd..8835c80b21 100644 --- a/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden @@ -11,7 +11,7 @@ func BuildMethodBodyInlineMapUserPayload(serviceBodyInlineMapUserMethodBodyInlin } v := make(map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, len(body)) for key, val := range body { - tk := marshalKeyTypeRequestBodyToServicebodyinlinemapuserKeyType(val) + tk := marshalKeyTypeRequestBodyToServicebodyinlinemapuserKeyType(key) if val == nil { v[tk] = nil continue diff --git a/http/codegen/testdata/golden/client_cli_payload-object-default-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-object-default-type.go.golden index 4ce075ee76..86615cfbbc 100644 --- a/http/codegen/testdata/golden/client_cli_payload-object-default-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-object-default-type.go.golden @@ -8,7 +8,7 @@ func BuildMethodBodyInlineObjectPayload(serviceBodyInlineObjectMethodBodyInlineO { err = json.Unmarshal([]byte(serviceBodyInlineObjectMethodBodyInlineObjectBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Magnam id itaque quo.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Fugit atque.\"\n }'") } } v := &servicebodyinlineobject.MethodBodyInlineObjectPayload{ diff --git a/http/codegen/testdata/golden/client_cli_payload-object-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-object-type.go.golden index a0564a8397..4448b25adb 100644 --- a/http/codegen/testdata/golden/client_cli_payload-object-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-object-type.go.golden @@ -8,7 +8,7 @@ func BuildMethodBodyInlineObjectPayload(serviceBodyInlineObjectMethodBodyInlineO { err = json.Unmarshal([]byte(serviceBodyInlineObjectMethodBodyInlineObjectBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Magnam id itaque quo.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Fugit atque.\"\n }'") } } v := &servicebodyinlineobject.MethodBodyInlineObjectPayload{ diff --git a/http/codegen/testdata/golden/client_cli_simple-build.go.golden b/http/codegen/testdata/golden/client_cli_simple-build.go.golden index 4b8d815c4b..54bce0f8e0 100644 --- a/http/codegen/testdata/golden/client_cli_simple-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_simple-build.go.golden @@ -6,7 +6,7 @@ func BuildMethodMultiSimplePayloadPayload(serviceMultiSimple1MethodMultiSimplePa { err = json.Unmarshal([]byte(serviceMultiSimple1MethodMultiSimplePayloadBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": false\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": true\n }'") } } v := &servicemultisimple1.MethodMultiSimplePayloadPayload{ diff --git a/http/codegen/testdata/golden/client_cli_with-params-and-headers-dsl.go.golden b/http/codegen/testdata/golden/client_cli_with-params-and-headers-dsl.go.golden index 6aad04fffb..dc98d50d28 100644 --- a/http/codegen/testdata/golden/client_cli_with-params-and-headers-dsl.go.golden +++ b/http/codegen/testdata/golden/client_cli_with-params-and-headers-dsl.go.golden @@ -6,7 +6,7 @@ func BuildMethodAPayload(serviceWithParamsAndHeadersBlockMethodABody string, ser { err = json.Unmarshal([]byte(serviceWithParamsAndHeadersBlockMethodABody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"body\": \"Molestias quia.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"body\": \"Dolores eos voluptas.\"\n }'") } } var path uint diff --git a/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden index 3dbbbb5f65..9fb3ced583 100644 --- a/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden @@ -3,18 +3,24 @@ // restoreBody controls whether the response body should be restored after // having been read. func DecodeMethodBodyMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -42,7 +48,10 @@ func DecodeMethodBodyMultipleViewResponse(decoder func(*http.Response) goahttp.D res := servicebodymultipleview.NewResulttypemultipleviews(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) + } return nil, goahttp.ErrInvalidResponse("ServiceBodyMultipleView", "MethodBodyMultipleView", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden index 4d1836051f..d4da39b9e5 100644 --- a/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden @@ -3,18 +3,24 @@ // MethodEmptyBodyResultMultipleView endpoint. restoreBody controls whether the // response body should be restored after having been read. func DecodeMethodEmptyBodyResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -31,7 +37,10 @@ func DecodeMethodEmptyBodyResultMultipleViewResponse(decoder func(*http.Response res := serviceemptybodyresultmultipleview.NewResulttypemultipleviews(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", err) + } return nil, goahttp.ErrInvalidResponse("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_empty-body.go.golden b/http/codegen/testdata/golden/client_decode_empty-body.go.golden index 76693be53d..de696c4d18 100644 --- a/http/codegen/testdata/golden/client_decode_empty-body.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-body.go.golden @@ -3,25 +3,34 @@ // endpoint. restoreBody controls whether the response body should be restored // after having been read. func DecodeMethodEmptyServerResponseResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyServerResponse", "MethodEmptyServerResponse", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceEmptyServerResponse", "MethodEmptyServerResponse", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: res := NewMethodEmptyServerResponseResultOK() return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyServerResponse", "MethodEmptyServerResponse", err) + } return nil, goahttp.ErrInvalidResponse("ServiceEmptyServerResponse", "MethodEmptyServerResponse", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_empty-error-response-body.go.golden b/http/codegen/testdata/golden/client_decode_empty-error-response-body.go.golden index e81fe10765..45abf451f5 100644 --- a/http/codegen/testdata/golden/client_decode_empty-error-response-body.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-error-response-body.go.golden @@ -7,18 +7,24 @@ // - "not_found" (type serviceemptyerrorresponsebody.NotFound): http.StatusNotFound // - error: internal error func DecodeMethodEmptyErrorResponseBodyResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -100,7 +106,10 @@ func DecodeMethodEmptyErrorResponseBodyResponse(decoder func(*http.Response) goa } return nil, NewMethodEmptyErrorResponseBodyNotFound(inHeader) default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err) + } return nil, goahttp.ErrInvalidResponse("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_empty-server-response-with-tags.go.golden b/http/codegen/testdata/golden/client_decode_empty-server-response-with-tags.go.golden index 486e080587..529e9df15a 100644 --- a/http/codegen/testdata/golden/client_decode_empty-server-response-with-tags.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-server-response-with-tags.go.golden @@ -3,18 +3,24 @@ // MethodEmptyServerResponseWithTags endpoint. restoreBody controls whether the // response body should be restored after having been read. func DecodeMethodEmptyServerResponseWithTagsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", err)) + } + }() } switch resp.StatusCode { case http.StatusNotModified: @@ -25,7 +31,10 @@ func DecodeMethodEmptyServerResponseWithTagsResponse(decoder func(*http.Response res := NewMethodEmptyServerResponseWithTagsResultNoContent() return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", err) + } return nil, goahttp.ErrInvalidResponse("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden b/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden index 45a3efe2ab..b67099477a 100644 --- a/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden +++ b/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden @@ -4,18 +4,24 @@ // MethodExplicitBodyPrimitiveResultMultipleView endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -49,7 +55,10 @@ func DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse(decoder func(*h res := serviceexplicitbodyprimitiveresultmultipleview.NewResulttypemultipleviews(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) + } return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_explicit-body-result-collection.go.golden b/http/codegen/testdata/golden/client_decode_explicit-body-result-collection.go.golden index 794f6fa6ba..3b33387e82 100644 --- a/http/codegen/testdata/golden/client_decode_explicit-body-result-collection.go.golden +++ b/http/codegen/testdata/golden/client_decode_explicit-body-result-collection.go.golden @@ -3,37 +3,46 @@ // MethodExplicitBodyResultCollection endpoint. restoreBody controls whether // the response body should be restored after having been read. func DecodeMethodExplicitBodyResultCollectionResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: var ( - body ResulttypeCollection + body ResulttypeResponseCollection err error ) err = decoder(resp).Decode(&body) if err != nil { return nil, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) } - err = ValidateResulttypeCollection(body) + err = ValidateResulttypeResponseCollection(body) if err != nil { return nil, goahttp.ErrValidationError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) } res := NewMethodExplicitBodyResultCollectionResultOK(body) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) + } return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden index 9b7d4c88ec..30b67796bf 100644 --- a/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden @@ -3,18 +3,24 @@ // MethodExplicitBodyUserResultMultipleView endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodExplicitBodyUserResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -42,7 +48,10 @@ func DecodeMethodExplicitBodyUserResultMultipleViewResponse(decoder func(*http.R res := serviceexplicitbodyuserresultmultipleview.NewResulttypemultipleviews(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) + } return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-array-validate.go.golden b/http/codegen/testdata/golden/client_decode_header-array-validate.go.golden index be64f05f20..4b9d930c32 100644 --- a/http/codegen/testdata/golden/client_decode_header-array-validate.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-array-validate.go.golden @@ -2,18 +2,24 @@ // ServiceHeaderArrayValidateResponse MethodA endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderArrayValidateResponse", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderArrayValidateResponse", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -46,7 +52,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(array) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderArrayValidateResponse", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderArrayValidateResponse", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-array.go.golden b/http/codegen/testdata/golden/client_decode_header-array.go.golden index b10174b8a2..d6c05ba6b8 100644 --- a/http/codegen/testdata/golden/client_decode_header-array.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-array.go.golden @@ -2,18 +2,24 @@ // ServiceHeaderArrayResponse MethodA endpoint. restoreBody controls whether // the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderArrayResponse", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderArrayResponse", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -41,7 +47,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(array) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderArrayResponse", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderArrayResponse", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-string-array-validate.go.golden b/http/codegen/testdata/golden/client_decode_header-string-array-validate.go.golden index a6ff6af677..918677d89e 100644 --- a/http/codegen/testdata/golden/client_decode_header-string-array-validate.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-string-array-validate.go.golden @@ -2,18 +2,24 @@ // ServiceHeaderStringArrayValidateResponse MethodA endpoint. restoreBody // controls whether the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringArrayValidateResponse", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderStringArrayValidateResponse", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -32,7 +38,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(array) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringArrayValidateResponse", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringArrayValidateResponse", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-string-array.go.golden b/http/codegen/testdata/golden/client_decode_header-string-array.go.golden index 94a679e52c..5cd520ddd8 100644 --- a/http/codegen/testdata/golden/client_decode_header-string-array.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-string-array.go.golden @@ -2,18 +2,24 @@ // ServiceHeaderStringArrayResponse MethodA endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringArrayResponse", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderStringArrayResponse", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -25,7 +31,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(array) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringArrayResponse", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringArrayResponse", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-string-implicit.go.golden b/http/codegen/testdata/golden/client_decode_header-string-implicit.go.golden index 2d2edbac47..505ac4c77a 100644 --- a/http/codegen/testdata/golden/client_decode_header-string-implicit.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-string-implicit.go.golden @@ -3,18 +3,24 @@ // endpoint. restoreBody controls whether the response body should be restored // after having been read. func DecodeMethodHeaderStringImplicitResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -32,7 +38,10 @@ func DecodeMethodHeaderStringImplicitResponse(decoder func(*http.Response) goaht } return h, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_required-primitive-arrays.go.golden b/http/codegen/testdata/golden/client_decode_required-primitive-arrays.go.golden new file mode 100644 index 0000000000..b341d3d2ef --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_required-primitive-arrays.go.golden @@ -0,0 +1,48 @@ +// DecodeStoreResponse returns a decoder for responses returned by the +// RequiredArrays Store endpoint. restoreBody controls whether the response +// body should be restored after having been read. +func DecodeStoreResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body + if restoreBody { + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("RequiredArrays", "Store", err) + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("RequiredArrays", "Store", err)) + } + }() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body StoreResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("RequiredArrays", "Store", err) + } + err = ValidateStoreResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("RequiredArrays", "Store", err) + } + res := NewStoreResultOK(&body) + return res, nil + default: + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("RequiredArrays", "Store", err) + } + return nil, goahttp.ErrInvalidResponse("RequiredArrays", "Store", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/client_decode_skip-response-body-encode-decode.go.golden b/http/codegen/testdata/golden/client_decode_skip-response-body-encode-decode.go.golden new file mode 100644 index 0000000000..4fc444bb66 --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_skip-response-body-encode-decode.go.golden @@ -0,0 +1,36 @@ +// DecodeMethodSkipResponseBodyEncodeDecodeResponse returns a decoder for +// responses returned by the ServiceSkipResponseBodyEncodeDecode +// MethodSkipResponseBodyEncodeDecode endpoint. restoreBody controls whether +// the response body should be restored after having been read. +// DecodeMethodSkipResponseBodyEncodeDecodeResponse may return the following +// errors: +// - "internal_error" (type *goa.ServiceError): http.StatusInternalServerError +// - error: internal error +func DecodeMethodSkipResponseBodyEncodeDecodeResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + switch resp.StatusCode { + case http.StatusOK: + return nil, nil + case http.StatusInternalServerError: + var ( + body MethodSkipResponseBodyEncodeDecodeInternalErrorResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceSkipResponseBodyEncodeDecode", "MethodSkipResponseBodyEncodeDecode", err) + } + err = ValidateMethodSkipResponseBodyEncodeDecodeInternalErrorResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("ServiceSkipResponseBodyEncodeDecode", "MethodSkipResponseBodyEncodeDecode", err) + } + return nil, NewMethodSkipResponseBodyEncodeDecodeInternalError(&body) + default: + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceSkipResponseBodyEncodeDecode", "MethodSkipResponseBodyEncodeDecode", err) + } + return nil, goahttp.ErrInvalidResponse("ServiceSkipResponseBodyEncodeDecode", "MethodSkipResponseBodyEncodeDecode", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden index 4420a82de0..4a5c17785f 100644 --- a/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden @@ -3,18 +3,24 @@ // restoreBody controls whether the response body should be restored after // having been read. func DecodeMethodTagMultipleViewsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err)) + } + }() } switch resp.StatusCode { case http.StatusAccepted: @@ -61,7 +67,10 @@ func DecodeMethodTagMultipleViewsResponse(decoder func(*http.Response) goahttp.D res := servicetagmultipleviews.NewResulttypemultipleviews(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) + } return nil, goahttp.ErrInvalidResponse("ServiceTagMultipleViews", "MethodTagMultipleViews", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden b/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden index 7042e4a30b..af4ac706d8 100644 --- a/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden +++ b/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden @@ -5,18 +5,24 @@ // - "some_error" (type *validateerrorresponsetype.AError): http.StatusBadRequest // - error: internal error func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ValidateErrorResponseType", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ValidateErrorResponseType", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -38,7 +44,7 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore if err != nil { return nil, goahttp.ErrValidationError("ValidateErrorResponseType", "MethodA", err) } - p := NewMethodAAResultOK(required) + p := NewMethodAResultOK(required) view := "default" vres := &validateerrorresponsetypeviews.AResult{Projected: p, View: view} res := validateerrorresponsetype.NewAResult(vres) @@ -75,7 +81,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore } return nil, NewMethodASomeError(error_, numOccur) default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ValidateErrorResponseType", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ValidateErrorResponseType", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden b/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden index 6e55a27963..e472224457 100644 --- a/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden +++ b/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden @@ -2,18 +2,24 @@ // ServiceWithHeadersBlockViewedResult MethodA endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceWithHeadersBlockViewedResult", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceWithHeadersBlockViewedResult", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -59,13 +65,16 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore if err != nil { return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", err) } - p := NewMethodAAResultOK(required, optional, optionalButRequired) + p := NewMethodAResultOK(required, optional, optionalButRequired) view := resp.Header.Get("goa-view") vres := &servicewithheadersblockviewedresultviews.AResult{Projected: p, View: view} res := servicewithheadersblockviewedresult.NewAResult(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceWithHeadersBlockViewedResult", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceWithHeadersBlockViewedResult", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_with-headers-dsl.go.golden b/http/codegen/testdata/golden/client_decode_with-headers-dsl.go.golden index c80495a8d8..5fbe40cfd4 100644 --- a/http/codegen/testdata/golden/client_decode_with-headers-dsl.go.golden +++ b/http/codegen/testdata/golden/client_decode_with-headers-dsl.go.golden @@ -2,18 +2,24 @@ // ServiceWithHeadersBlock MethodA endpoint. restoreBody controls whether the // response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceWithHeadersBlock", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceWithHeadersBlock", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -62,7 +68,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(required, optional, optionalButRequired) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceWithHeadersBlock", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceWithHeadersBlock", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_encode_query-array-float32-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-array-float32-validate.go.golden index bc8331e979..55f4313620 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-float32-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-float32-validate.go.golden @@ -9,7 +9,7 @@ func EncodeMethodQueryArrayFloat32ValidateRequest(encoder func(*http.Request) go } values := req.URL.Query() for _, value := range p.Q { - valueStr := strconv.FormatFloat(float64(value), 'f', -1, 32) + valueStr := strconv.FormatFloat(float64(value), 'g', -1, 32) values.Add("q", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-array-float32.go.golden b/http/codegen/testdata/golden/client_encode_query-array-float32.go.golden index 6589621437..c4c139da56 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-float32.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-float32.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryArrayFloat32Request(encoder func(*http.Request) goahttp.En } values := req.URL.Query() for _, value := range p.Q { - valueStr := strconv.FormatFloat(float64(value), 'f', -1, 32) + valueStr := strconv.FormatFloat(float64(value), 'g', -1, 32) values.Add("q", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-array-float64-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-array-float64-validate.go.golden index b933344476..58d87c3625 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-float64-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-float64-validate.go.golden @@ -9,7 +9,7 @@ func EncodeMethodQueryArrayFloat64ValidateRequest(encoder func(*http.Request) go } values := req.URL.Query() for _, value := range p.Q { - valueStr := strconv.FormatFloat(value, 'f', -1, 64) + valueStr := strconv.FormatFloat(value, 'g', -1, 64) values.Add("q", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-array-float64.go.golden b/http/codegen/testdata/golden/client_encode_query-array-float64.go.golden index 7a44c90fc7..611043286d 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-float64.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-float64.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryArrayFloat64Request(encoder func(*http.Request) goahttp.En } values := req.URL.Query() for _, value := range p.Q { - valueStr := strconv.FormatFloat(value, 'f', -1, 64) + valueStr := strconv.FormatFloat(value, 'g', -1, 64) values.Add("q", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-array-nested-alias-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-array-nested-alias-validate.go.golden index 263eee9ae4..5797d576e9 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-nested-alias-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-nested-alias-validate.go.golden @@ -8,7 +8,7 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() for _, value := range p.Array { - valueStr := strconv.FormatFloat(float64(value), 'f', -1, 64) + valueStr := strconv.FormatFloat(float64(value), 'g', -1, 64) values.Add("array", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-bool-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-bool-validate.go.golden index 6d1cc0ac23..c5195f8124 100644 --- a/http/codegen/testdata/golden/client_encode_query-bool-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-bool-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryBoolValidateRequest(encoder func(*http.Request) goahttp.En return goahttp.ErrInvalidType("ServiceQueryBoolValidate", "MethodQueryBoolValidate", "*servicequeryboolvalidate.MethodQueryBoolValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatBool(p.Q)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-bool.go.golden b/http/codegen/testdata/golden/client_encode_query-bool.go.golden index cdc6afae49..00936be1cd 100644 --- a/http/codegen/testdata/golden/client_encode_query-bool.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-bool.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryBoolRequest(encoder func(*http.Request) goahttp.Encoder) f } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatBool(*p.Q)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-float32-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-float32-validate.go.golden index 72fc208076..3f4ad6f02d 100644 --- a/http/codegen/testdata/golden/client_encode_query-float32-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-float32-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryFloat32ValidateRequest(encoder func(*http.Request) goahttp return goahttp.ErrInvalidType("ServiceQueryFloat32Validate", "MethodQueryFloat32Validate", "*servicequeryfloat32validate.MethodQueryFloat32ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatFloat(float64(p.Q), 'g', -1, 32)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-float32.go.golden b/http/codegen/testdata/golden/client_encode_query-float32.go.golden index f75be80096..e7e1060fe7 100644 --- a/http/codegen/testdata/golden/client_encode_query-float32.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-float32.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryFloat32Request(encoder func(*http.Request) goahttp.Encoder } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatFloat(float64(*p.Q), 'g', -1, 32)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-float64-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-float64-validate.go.golden index 40c55bf7cc..ae96242174 100644 --- a/http/codegen/testdata/golden/client_encode_query-float64-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-float64-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryFloat64ValidateRequest(encoder func(*http.Request) goahttp return goahttp.ErrInvalidType("ServiceQueryFloat64Validate", "MethodQueryFloat64Validate", "*servicequeryfloat64validate.MethodQueryFloat64ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatFloat(p.Q, 'g', -1, 64)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-float64.go.golden b/http/codegen/testdata/golden/client_encode_query-float64.go.golden index 7949ba85ee..532b403c57 100644 --- a/http/codegen/testdata/golden/client_encode_query-float64.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-float64.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryFloat64Request(encoder func(*http.Request) goahttp.Encoder } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatFloat(*p.Q, 'g', -1, 64)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int-alias-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-int-alias-validate.go.golden index a06d7c3a09..40e75667d2 100644 --- a/http/codegen/testdata/golden/client_encode_query-int-alias-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int-alias-validate.go.golden @@ -8,13 +8,13 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() if p.Int != nil { - values.Add("int", fmt.Sprintf("%v", *p.Int)) + values.Add("int", strconv.Itoa(int(*p.Int))) } if p.Int32 != nil { - values.Add("int32", fmt.Sprintf("%v", *p.Int32)) + values.Add("int32", strconv.FormatInt(int64(*p.Int32), 10)) } if p.Int64 != nil { - values.Add("int64", fmt.Sprintf("%v", *p.Int64)) + values.Add("int64", strconv.FormatInt(int64(*p.Int64), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int-alias.go.golden b/http/codegen/testdata/golden/client_encode_query-int-alias.go.golden index e4033a47a7..84ef7aa3a5 100644 --- a/http/codegen/testdata/golden/client_encode_query-int-alias.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int-alias.go.golden @@ -8,13 +8,13 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() if p.Int != nil { - values.Add("int", fmt.Sprintf("%v", *p.Int)) + values.Add("int", strconv.Itoa(int(*p.Int))) } if p.Int32 != nil { - values.Add("int32", fmt.Sprintf("%v", *p.Int32)) + values.Add("int32", strconv.FormatInt(int64(*p.Int32), 10)) } if p.Int64 != nil { - values.Add("int64", fmt.Sprintf("%v", *p.Int64)) + values.Add("int64", strconv.FormatInt(int64(*p.Int64), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-int-validate.go.golden index 1d51b83c44..9b07ffec50 100644 --- a/http/codegen/testdata/golden/client_encode_query-int-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryIntValidateRequest(encoder func(*http.Request) goahttp.Enc return goahttp.ErrInvalidType("ServiceQueryIntValidate", "MethodQueryIntValidate", "*servicequeryintvalidate.MethodQueryIntValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.Itoa(p.Q)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-int.go.golden b/http/codegen/testdata/golden/client_encode_query-int.go.golden index cf129af0b3..66ad1cd02d 100644 --- a/http/codegen/testdata/golden/client_encode_query-int.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryIntRequest(encoder func(*http.Request) goahttp.Encoder) fu } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.Itoa(*p.Q)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int32-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-int32-validate.go.golden index 6d1ee581d2..8e4e61624c 100644 --- a/http/codegen/testdata/golden/client_encode_query-int32-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int32-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryInt32ValidateRequest(encoder func(*http.Request) goahttp.E return goahttp.ErrInvalidType("ServiceQueryInt32Validate", "MethodQueryInt32Validate", "*servicequeryint32validate.MethodQueryInt32ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatInt(int64(p.Q), 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-int32.go.golden b/http/codegen/testdata/golden/client_encode_query-int32.go.golden index 2da13e32cf..cda9da2969 100644 --- a/http/codegen/testdata/golden/client_encode_query-int32.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int32.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryInt32Request(encoder func(*http.Request) goahttp.Encoder) } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatInt(int64(*p.Q), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int64-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-int64-validate.go.golden index 9409b6b703..5b366b0879 100644 --- a/http/codegen/testdata/golden/client_encode_query-int64-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int64-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryInt64ValidateRequest(encoder func(*http.Request) goahttp.E return goahttp.ErrInvalidType("ServiceQueryInt64Validate", "MethodQueryInt64Validate", "*servicequeryint64validate.MethodQueryInt64ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatInt(p.Q, 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-int64.go.golden b/http/codegen/testdata/golden/client_encode_query-int64.go.golden index 98bc713141..24e74a0c4e 100644 --- a/http/codegen/testdata/golden/client_encode_query-int64.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int64.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryInt64Request(encoder func(*http.Request) goahttp.Encoder) } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatInt(*p.Q, 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-map-alias-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-map-alias-validate.go.golden index 1bfa849733..19fe87a03c 100644 --- a/http/codegen/testdata/golden/client_encode_query-map-alias-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-map-alias-validate.go.golden @@ -8,7 +8,7 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() for kRaw, value := range p.Map { - k := strconv.FormatFloat(float64(kRaw), 'f', -1, 32) + k := strconv.FormatFloat(float64(kRaw), 'g', -1, 32) key := fmt.Sprintf("map[%s]", k) valueStr := strconv.FormatBool(value) values.Add(key, valueStr) diff --git a/http/codegen/testdata/golden/client_encode_query-map-alias.go.golden b/http/codegen/testdata/golden/client_encode_query-map-alias.go.golden index 0bae2e60fd..d40207f329 100644 --- a/http/codegen/testdata/golden/client_encode_query-map-alias.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-map-alias.go.golden @@ -8,7 +8,7 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() for kRaw, value := range p.Map { - k := strconv.FormatFloat(float64(kRaw), 'f', -1, 32) + k := strconv.FormatFloat(float64(kRaw), 'g', -1, 32) key := fmt.Sprintf("map[%s]", k) valueStr := strconv.FormatBool(value) values.Add(key, valueStr) diff --git a/http/codegen/testdata/golden/client_encode_query-uint-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-uint-validate.go.golden index 3784cc4fcc..603b7ff12e 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryUIntValidateRequest(encoder func(*http.Request) goahttp.En return goahttp.ErrInvalidType("ServiceQueryUIntValidate", "MethodQueryUIntValidate", "*servicequeryuintvalidate.MethodQueryUIntValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatUint(uint64(p.Q), 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-uint.go.golden b/http/codegen/testdata/golden/client_encode_query-uint.go.golden index 5541e09192..00f05680e9 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryUIntRequest(encoder func(*http.Request) goahttp.Encoder) f } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatUint(uint64(*p.Q), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-uint32-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-uint32-validate.go.golden index 8d749033d1..d2edbd9dac 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint32-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint32-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryUInt32ValidateRequest(encoder func(*http.Request) goahttp. return goahttp.ErrInvalidType("ServiceQueryUInt32Validate", "MethodQueryUInt32Validate", "*servicequeryuint32validate.MethodQueryUInt32ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatUint(uint64(p.Q), 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-uint32.go.golden b/http/codegen/testdata/golden/client_encode_query-uint32.go.golden index b97ad4710f..17710b93ee 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint32.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint32.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryUInt32Request(encoder func(*http.Request) goahttp.Encoder) } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatUint(uint64(*p.Q), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-uint64-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-uint64-validate.go.golden index 42975304c4..0622c17f8f 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint64-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint64-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryUInt64ValidateRequest(encoder func(*http.Request) goahttp. return goahttp.ErrInvalidType("ServiceQueryUInt64Validate", "MethodQueryUInt64Validate", "*servicequeryuint64validate.MethodQueryUInt64ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatUint(p.Q, 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-uint64.go.golden b/http/codegen/testdata/golden/client_encode_query-uint64.go.golden index 0aaa4166d5..fb2f305cbd 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint64.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint64.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryUInt64Request(encoder func(*http.Request) goahttp.Encoder) } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatUint(*p.Q, 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_skip-request-body-header.go.golden b/http/codegen/testdata/golden/client_encode_skip-request-body-header.go.golden new file mode 100644 index 0000000000..754d23d39d --- /dev/null +++ b/http/codegen/testdata/golden/client_encode_skip-request-body-header.go.golden @@ -0,0 +1,16 @@ +// EncodeUploadRequest returns an encoder for requests sent to the +// SkipRequestBodyEncodeDecodeHeader Upload server. +func EncodeUploadRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + data, ok := v.(*skiprequestbodyencodedecodeheader.UploadRequestData) + if !ok { + return goahttp.ErrInvalidType("SkipRequestBodyEncodeDecodeHeader", "Upload", "*skiprequestbodyencodedecodeheader.UploadRequestData", v) + } + p := data.Payload + if p.ContentType != nil { + head := *p.ContentType + req.Header.Set("Content-Type", head) + } + return nil + } +} diff --git a/http/codegen/testdata/golden/client_endpoint_response_body_lifecycle.go.golden b/http/codegen/testdata/golden/client_endpoint_response_body_lifecycle.go.golden new file mode 100644 index 0000000000..040dc57a19 --- /dev/null +++ b/http/codegen/testdata/golden/client_endpoint_response_body_lifecycle.go.golden @@ -0,0 +1,79 @@ +// Read returns an endpoint that makes HTTP requests to the body_lifecycle +// service read server. +func (c *Client) Read() goa.Endpoint { + var ( + decodeResponse = DecodeReadResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildReadRequest(ctx, v) + if err != nil { + return nil, err + } + resp, err := c.ReadDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("body_lifecycle", "read", err) + } + return decodeResponse(resp) + } +} + +// Raw returns an endpoint that makes HTTP requests to the body_lifecycle +// service raw server. +func (c *Client) Raw() goa.Endpoint { + var ( + decodeResponse = DecodeRawResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildRawRequest(ctx, v) + if err != nil { + return nil, err + } + resp, err := c.RawDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("body_lifecycle", "raw", err) + } + _, err = decodeResponse(resp) + if err != nil { + if closeErr := resp.Body.Close(); closeErr != nil { + return nil, errors.Join(err, goahttp.ErrDecodingError("body_lifecycle", "raw", closeErr)) + } + return nil, err + } + return &bodylifecycle.RawResponseData{Body: resp.Body}, nil + } +} + +// Watch returns an endpoint that makes HTTP requests to the body_lifecycle +// service watch server. +func (c *Client) Watch() goa.Endpoint { + var ( + decodeResponse = DecodeWatchResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildWatchRequest(ctx, v) + if err != nil { + return nil, err + } + // For SSE endpoints, connect and return a stream + resp, err := c.WatchDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("body_lifecycle", "watch", err) + } + + if resp.StatusCode != http.StatusOK { + // Decode designed errors (the decoder closes the response body). + return decodeResponse(resp) + } + + contentType := resp.Header.Get("Content-Type") + if contentType != "" && !strings.HasPrefix(contentType, "text/event-stream") { + contentTypeErr := fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + if err := resp.Body.Close(); err != nil { + return nil, errors.Join(contentTypeErr, goahttp.ErrDecodingError("body_lifecycle", "watch", err)) + } + return nil, contentTypeErr + } + + return NewWatchStream(resp, c.decoder), nil + } +} diff --git a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden index ae60cad639..0160eb2162 100644 --- a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden +++ b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden @@ -76,7 +76,7 @@ func ValidateListResponseBody(body *ListResponseBody) (err error) { } // ValidateListSomethingWentWrongResponseBody runs the validations defined on -// list_something_went_wrong_response_body +// ListSomethingWentWrongResponseBody func ValidateListSomethingWentWrongResponseBody(body *ListSomethingWentWrongResponseBody) (err error) { if body.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) diff --git a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden index cd45f6401a..66d816b2f2 100644 --- a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden +++ b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden @@ -76,7 +76,7 @@ func ValidateListResponseBody(body *ListResponseBody) (err error) { } // ValidateListSomethingWentWrongResponseBody runs the validations defined on -// list_something_went_wrong_response_body +// ListSomethingWentWrongResponseBody func ValidateListSomethingWentWrongResponseBody(body *ListSomethingWentWrongResponseBody) (err error) { if body.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) diff --git a/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden b/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden index 2293ad85d5..5cadde07fb 100644 --- a/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden +++ b/http/codegen/testdata/golden/client_types_client-mixed-payload-attrs.go.golden @@ -40,7 +40,7 @@ func NewMethodARequestBody(p *servicemixedpayloadinbody.APayload) *MethodAReques body.Object = marshalServicemixedpayloadinbodyBPayloadToBPayloadRequestBody(p.Object) } if p.DupObj != nil { - body.DupObj = marshalServicemixedpayloadinbodyBPayloadToBPayloadRequestBody(p.DupObj) + body.DupObj = marshalServicemixedpayloadinbodyBPayloadToBPayloadRequestBodyOptional(p.DupObj) } return body } diff --git a/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden b/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden index 4c4bf9af70..1b8a0d841a 100644 --- a/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden +++ b/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden @@ -37,8 +37,7 @@ func NewPayloadBRequestBody(p []*servicemultiplemethods.PayloadB) []*PayloadBReq return body } -// ValidatePayloadARequestBody runs the validations defined on -// PayloadARequestBody +// ValidatePayloadARequestBody runs the validations defined on PayloadA func ValidatePayloadARequestBody(body *PayloadARequestBody) (err error) { if body.A != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", *body.A, "patterna")) @@ -46,8 +45,7 @@ func ValidatePayloadARequestBody(body *PayloadARequestBody) (err error) { return } -// ValidatePayloadBRequestBody runs the validations defined on -// PayloadBRequestBody +// ValidatePayloadBRequestBody runs the validations defined on PayloadB func ValidatePayloadBRequestBody(body *PayloadBRequestBody) (err error) { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", body.A, "patterna")) err = goa.MergeErrors(err, goa.ValidatePattern("body.b", body.B, "patternb")) diff --git a/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden b/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden index 46d896109a..f6a9ffa29d 100644 --- a/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden +++ b/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden @@ -39,11 +39,19 @@ func NewMethodBRequestBody(p *servicemultiplemethods.PayloadType) *MethodBReques return body } -// ValidateAPayloadRequestBody runs the validations defined on -// APayloadRequestBody +// ValidateAPayloadRequestBody runs the validations defined on APayload func ValidateAPayloadRequestBody(body *APayloadRequestBody) (err error) { if body.A != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", *body.A, "patterna")) } return } + +// validateAPayloadRequestBody checks APayload and reports errors using the +// path supplied by its caller +func validateAPayloadRequestBody(body *APayloadRequestBody, path string) (err error) { + if body.A != nil { + err = goa.MergeErrors(err, goa.ValidatePattern(path+".a", *body.A, "patterna")) + } + return +} diff --git a/http/codegen/testdata/golden/client_types_client-required-primitive-arrays.go.golden b/http/codegen/testdata/golden/client_types_client-required-primitive-arrays.go.golden new file mode 100644 index 0000000000..5e75b8f1aa --- /dev/null +++ b/http/codegen/testdata/golden/client_types_client-required-primitive-arrays.go.golden @@ -0,0 +1,76 @@ +// StoreRequestBody is the type of the "RequiredArrays" service "Store" +// endpoint HTTP request body. +type StoreRequestBody struct { + Names []string `form:"names" json:"names" xml:"names"` + Aliases []string `form:"aliases" json:"aliases" xml:"aliases"` +} + +// StoreResponseBody is the type of the "RequiredArrays" service "Store" +// endpoint HTTP response body. +type StoreResponseBody struct { + Names []*string `form:"names,omitempty" json:"names,omitempty" xml:"names,omitempty"` + Aliases []*string `form:"aliases,omitempty" json:"aliases,omitempty" xml:"aliases,omitempty"` +} + +// NewStoreRequestBody builds the HTTP request body from the payload of the +// "Store" endpoint of the "RequiredArrays" service. +func NewStoreRequestBody(p *requiredarrays.StorePayload) *StoreRequestBody { + body := &StoreRequestBody{} + if p.Names != nil { + body.Names = make([]string, len(p.Names)) + for i, val := range p.Names { + body.Names[i] = val + } + } else { + body.Names = []string{} + } + if p.Aliases != nil { + body.Aliases = make([]string, len(p.Aliases)) + for i, val := range p.Aliases { + body.Aliases[i] = string(val) + } + } else { + body.Aliases = []string{} + } + return body +} + +// NewStoreResultOK builds a "RequiredArrays" service "Store" endpoint result +// from a HTTP "OK" response. +func NewStoreResultOK(body *StoreResponseBody) *requiredarrays.StoreResult { + v := &requiredarrays.StoreResult{} + v.Names = make([]string, len(body.Names)) + for i, val := range body.Names { + v.Names[i] = *val + } + v.Aliases = make([]requiredarrays.RequiredArrayAlias, len(body.Aliases)) + for i, val := range body.Aliases { + v.Aliases[i] = requiredarrays.RequiredArrayAlias(*val) + } + + return v +} + +// ValidateStoreResponseBody runs the validations defined on StoreResponseBody +func ValidateStoreResponseBody(body *StoreResponseBody) (err error) { + if body.Names == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("names", "body")) + } + if body.Aliases == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("aliases", "body")) + } + for _, e := range body.Names { + if e == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("body.names", "[*]")) + } + } + for _, e := range body.Aliases { + if e == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("body.aliases", "[*]")) + } + if e != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.aliases[*]", *e, "^[a-z]+$")) + } + } + return +} diff --git a/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden b/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden index 9d47f2ddc7..95cc3a5ee5 100644 --- a/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden @@ -16,7 +16,7 @@ func NewMethodWithErrorCustomPkgErrorName(body *MethodWithErrorCustomPkgErrorNam } // ValidateMethodWithErrorCustomPkgErrorNameResponseBody runs the validations -// defined on MethodWithErrorCustomPkg_error_name_Response_Body +// defined on MethodWithErrorCustomPkgErrorNameResponseBody func ValidateMethodWithErrorCustomPkgErrorNameResponseBody(body *MethodWithErrorCustomPkgErrorNameResponseBody) (err error) { if body.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) diff --git a/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden b/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden index 69eb2409af..4af6473a52 100644 --- a/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden @@ -24,7 +24,7 @@ type RtResponseBody struct { func NewMethodResultWithResultCollectionResultOK(body *MethodResultWithResultCollectionResponseBody) *serviceresultwithresultcollection.MethodResultWithResultCollectionResult { v := &serviceresultwithresultcollection.MethodResultWithResultCollectionResult{} if body.A != nil { - v.A = unmarshalResulttypeResponseBodyToServiceresultwithresultcollectionResulttype(body.A) + v.A = unmarshalResulttypeResponseBodyToServiceresultwithresultcollectionResulttypeOptional(body.A) } return v @@ -34,30 +34,39 @@ func NewMethodResultWithResultCollectionResultOK(body *MethodResultWithResultCol // defined on MethodResultWithResultCollectionResponseBody func ValidateMethodResultWithResultCollectionResponseBody(body *MethodResultWithResultCollectionResponseBody) (err error) { if body.A != nil { - if err2 := ValidateResulttypeResponseBody(body.A); err2 != nil { + if err2 := validateResulttypeResponseBody(body.A, "body.a"); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateResulttypeResponseBody runs the validations defined on -// ResulttypeResponseBody +// ValidateResulttypeResponseBody runs the validations defined on Resulttype func ValidateResulttypeResponseBody(body *ResulttypeResponseBody) (err error) { if body.X != nil { - if err2 := ValidateRtCollectionResponseBody(body.X); err2 != nil { + if err2 := validateRtCollectionResponseBody(body.X, "body.x"); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateRtCollectionResponseBody runs the validations defined on -// RtCollectionResponseBody +// validateResulttypeResponseBody checks Resulttype and reports errors using +// the path supplied by its caller +func validateResulttypeResponseBody(body *ResulttypeResponseBody, path string) (err error) { + if body.X != nil { + if err2 := validateRtCollectionResponseBody(body.X, path+".x"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateRtCollectionResponseBody runs the validations defined on RtCollection func ValidateRtCollectionResponseBody(body RtCollectionResponseBody) (err error) { for _, e := range body { if e != nil { - if err2 := ValidateRtResponseBody(e); err2 != nil { + if err2 := validateRtResponseBody(e, "body[*]"); err2 != nil { err = goa.MergeErrors(err, err2) } } @@ -65,7 +74,20 @@ func ValidateRtCollectionResponseBody(body RtCollectionResponseBody) (err error) return } -// ValidateRtResponseBody runs the validations defined on RtResponseBody +// validateRtCollectionResponseBody checks RtCollection and reports errors +// using the path supplied by its caller +func validateRtCollectionResponseBody(body RtCollectionResponseBody, path string) (err error) { + for _, e := range body { + if e != nil { + if err2 := validateRtResponseBody(e, path+"[*]"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + return +} + +// ValidateRtResponseBody runs the validations defined on Rt func ValidateRtResponseBody(body *RtResponseBody) (err error) { if body.X != nil { if utf8.RuneCountInString(*body.X) < 5 { @@ -74,3 +96,14 @@ func ValidateRtResponseBody(body *RtResponseBody) (err error) { } return } + +// validateRtResponseBody checks Rt and reports errors using the path supplied +// by its caller +func validateRtResponseBody(body *RtResponseBody, path string) (err error) { + if body.X != nil { + if utf8.RuneCountInString(*body.X) < 5 { + err = goa.MergeErrors(err, goa.InvalidLengthError(path+".x", *body.X, utf8.RuneCountInString(*body.X), 5, true)) + } + } + return +} diff --git a/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden b/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden index 766af2025c..4650d541ee 100644 --- a/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-result-view.go.golden @@ -19,7 +19,7 @@ func NewMethodResultWithResultViewResulttypeOK(body *MethodResultWithResultViewR Name: body.Name, } if body.Rt != nil { - v.Rt = unmarshalRtResponseBodyToServiceresultwithresultviewviewsRtView(body.Rt) + v.Rt = unmarshalRtResponseBodyToServiceresultwithresultviewviewsRtViewOptional(body.Rt) } return v diff --git a/http/codegen/testdata/golden/planned_jsonrpc_validator_collisions.go.golden b/http/codegen/testdata/golden/planned_jsonrpc_validator_collisions.go.golden new file mode 100644 index 0000000000..3ef2477c0b --- /dev/null +++ b/http/codegen/testdata/golden/planned_jsonrpc_validator_collisions.go.golden @@ -0,0 +1,49 @@ +// ChooseRequestBody2 is the type of the "Names" service "Choose" endpoint HTTP +// request body. +type ChooseRequestBody2 struct { + Value *string `form:"value,omitempty" json:"value,omitempty" xml:"value,omitempty"` +} + +// ValidateChooseRequestBody2 runs the validations defined on ChooseRequestBody2 +func ValidateChooseRequestBody2(body *ChooseRequestBody2) (err error) { + if body.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "body")) + } + if body.Value != nil { + if utf8.RuneCountInString(*body.Value) < 2 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.value", *body.Value, utf8.RuneCountInString(*body.Value), 2, true)) + } + } + return +} + +// DecodeChooseRequest2 returns a decoder for requests sent to the Names Choose +// endpoint. +func DecodeChooseRequest2(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*names.ChoosePayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*names.ChoosePayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *names.ChoosePayload + var ( + body ChooseRequestBody2 + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateChooseRequestBody2(&body) + if err != nil { + return payload, err + } + payload = NewChoosePayload(&body) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/planned_name_collisions.go.golden b/http/codegen/testdata/golden/planned_name_collisions.go.golden new file mode 100644 index 0000000000..e463e477fb --- /dev/null +++ b/http/codegen/testdata/golden/planned_name_collisions.go.golden @@ -0,0 +1,189 @@ +// BuildCompleteRequest2 instantiates a HTTP request object with method and +// path set to call the "Names" service "Complete" endpoint +func (c *Client) BuildCompleteRequest2(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: CompleteNamesPath()} + req, err := http.NewRequest("POST", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("Names", "Complete", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// Complete returns an endpoint that makes HTTP requests to the Names service +// Complete server. +func (c *Client) Complete() goa.Endpoint { + var ( + encodeRequest = EncodeCompleteRequest(c.encoder) + decodeResponse = DecodeCompleteResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildCompleteRequest2(ctx, v) + if err != nil { + return nil, err + } + err = encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.CompleteDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("Names", "Complete", err) + } + return decodeResponse(resp) + } +} + +// ChildPayloadRequestBody2 is used to define fields on request body types. +type ChildPayloadRequestBody2 struct { + Value *string `form:"value,omitempty" json:"value,omitempty" xml:"value,omitempty"` +} + +// ValidateCompleteRequestBody runs the validations defined on +// CompleteRequestBody +func ValidateCompleteRequestBody(body *CompleteRequestBody) (err error) { + if body.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "body")) + } + if body.Child != nil { + if err2 := validateChildPayloadRequestBody2(body.Child, "body.child"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateChildPayloadRequestBody2 runs the validations defined on ChildPayload +func ValidateChildPayloadRequestBody2(body *ChildPayloadRequestBody2) (err error) { + if body.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "body")) + } + if body.Value != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.value", *body.Value, "value")) + } + return +} + +// validateChildPayloadRequestBody2 checks ChildPayload and reports errors +// using the path supplied by its caller +func validateChildPayloadRequestBody2(body *ChildPayloadRequestBody2, path string) (err error) { + if body.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", path)) + } + if body.Value != nil { + err = goa.MergeErrors(err, goa.ValidatePattern(path+".value", *body.Value, "value")) + } + return +} + +// DecodeCompleteRequest returns a decoder for requests sent to the Names +// Complete endpoint. +func DecodeCompleteRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*names.CompletePayload, error) { + return func(r *http.Request) (*names.CompletePayload, error) { + var payload *names.CompletePayload + var ( + body CompleteRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateCompleteRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewCompletePayload(&body) + + return payload, nil + } +} + +// SocketServerStream2 implements the names.SocketServerStream interface. +type SocketServerStream2 struct { + once sync.Once + // upgradeErr is the error returned by the websocket upgrade attempt. + upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error + // upgrader is the websocket connection upgrader. + upgrader goahttp.Upgrader + // configurer is the websocket connection configurer. + configurer goahttp.ConnConfigureFunc + // cancel is the context cancellation function which cancels the request + // context when invoked. + cancel context.CancelFunc + // w is the HTTP response writer used in upgrading the connection. + w http.ResponseWriter + // r is the HTTP request. + r *http.Request + // conn is the underlying websocket connection. + conn *websocket.Conn +} + +// NewSocketHandler creates a HTTP handler which loads the HTTP request and +// calls the "Names" service "Socket" endpoint. +func NewSocketHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, + upgrader goahttp.Upgrader, + configurer goahttp.ConnConfigureFunc, +) http.Handler { + var ( + encodeError = goahttp.ErrorEncoder(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "Socket") + ctx = context.WithValue(ctx, goa.ServiceKey, "Names") + var err error + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + v := &names.SocketEndpointInput{ + Stream: &SocketServerStream2{ + upgrader: upgrader, + configurer: configurer, + cancel: cancel, + w: w, + r: r, + }, + } + _, err = endpoint(ctx, v) + if err != nil { + var stream *SocketServerStream2 + if wrapper, ok := v.Stream.(interface{ Unwrap() any }); ok { + stream = wrapper.Unwrap().(*SocketServerStream2) + } else { + stream = v.Stream.(*SocketServerStream2) + } + if stream != nil && stream.conn != nil { + // Response writer has been hijacked, do not encode the error + if errhandler != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeError(ctx, w, err); err != nil && errhandler != nil { + errhandler(ctx, w, err) + } + return + } + }) +} diff --git a/http/codegen/testdata/golden/planned_service_name_uses.go.golden b/http/codegen/testdata/golden/planned_service_name_uses.go.golden new file mode 100644 index 0000000000..3ee1a1dd8f --- /dev/null +++ b/http/codegen/testdata/golden/planned_service_name_uses.go.golden @@ -0,0 +1,370 @@ +===== service method names definition ===== +// Service is the Collisions service interface. +type Service interface { + // Read implements Read. + Read(context.Context, *ReadPayload) (res string, err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "Name Test" + +// 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 = "Collisions" + +// 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 MethodNames2 = [1]string{"Read"} + +===== service endpoints definition ===== +// Endpoints2 wraps the "Collisions" service endpoints. +type Endpoints2 struct { + Read goa.Endpoint +} + +===== client interceptors definition ===== +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors2 interface { + Trace(ctx context.Context, info TraceInfo, next goa.Endpoint) (any, error) +} + +===== client endpoint wrapper definition ===== +// WrapReadClientEndpoint2 wraps the Read endpoint with the client interceptors +// defined in the design. +func WrapReadClientEndpoint2(endpoint goa.Endpoint, i ClientInterceptors2) goa.Endpoint { + if i != nil { + endpoint = wrapClientReadTrace(endpoint, i) + } + return endpoint +} + +===== HTTP server endpoints use ===== +// New instantiates HTTP handlers for all the Collisions service endpoints +// using the provided encoder and decoder. The handlers are mounted on the +// given mux using the HTTP verb and path defined in the design. errhandler is +// called whenever a response fails to be encoded. formatter is used to format +// errors returned by the service methods prior to encoding. Both errhandler +// and formatter are optional and can be nil. +func New( + e *collisions.Endpoints2, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) *Server { + return &Server{ + Mounts: []*MountPoint{ + {"Read", "POST", "/read"}, + }, + Read: NewReadHandler(e.Read, mux, decoder, encoder, errhandler, formatter), + } +} + +===== HTTP server method names use ===== +// MethodNames returns the methods served. +func (s *Server) MethodNames() []string { return collisions.MethodNames2[:] } + +===== HTTP command parser ===== +// ParseEndpoint returns the endpoint and payload as specified on the command +// line. +func ParseEndpoint( + scheme, host string, + doer goahttp.Doer, + enc func(*http.Request) goahttp.Encoder, + dec func(*http.Response) goahttp.Decoder, + restore bool, + collisionsInter collisions.ClientInterceptors2, +) (goa.Endpoint, any, error) { + var ( + collisionsFlags = flag.NewFlagSet("collisions", flag.ContinueOnError) + + collisionsReadFlags = flag.NewFlagSet("read", flag.ExitOnError) + collisionsReadBodyFlag = collisionsReadFlags.String("body", "REQUIRED", "") + ) + collisionsFlags.Usage = collisionsUsage + collisionsReadFlags.Usage = collisionsReadUsage + + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + return nil, nil, err + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + svcn string + svcf *flag.FlagSet + ) + { + svcn = flag.Arg(0) + switch svcn { + case "collisions": + svcf = collisionsFlags + default: + return nil, nil, fmt.Errorf("unknown service %q", svcn) + } + } + if err := svcf.Parse(flag.Args()[1:]); err != nil { + return nil, nil, err + } + + var ( + epn string + epf *flag.FlagSet + ) + { + epn = svcf.Arg(0) + switch svcn { + case "collisions": + switch epn { + case "read": + epf = collisionsReadFlags + + } + + } + } + if epf == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", svcn, epn) + } + + // Parse endpoint flags if any + if svcf.NArg() > 1 { + if err := epf.Parse(svcf.Args()[1:]); err != nil { + return nil, nil, err + } + } + + var ( + data any + endpoint goa.Endpoint + err error + ) + { + switch svcn { + case "collisions": + c := collisionsc.NewClient(scheme, host, doer, enc, dec, restore) + switch epn { + case "read": + endpoint = c.Read() + endpoint = collisions.WrapReadClientEndpoint2(endpoint, collisionsInter) + data, err = collisionsc.BuildReadPayload(*collisionsReadBodyFlag) + } + } + } + if err != nil { + return nil, nil, err + } + + return endpoint, data, nil +} + +===== HTTP example client interceptor use ===== +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { + var ( + doer goahttp.Doer + collisionsInterceptors collisions.ClientInterceptors2 + ) + { + doer = &http.Client{Timeout: time.Duration(timeout) * time.Second} + if debug { + doer = goahttp.NewDebugDoer(doer) + } + collisionsInterceptors = interceptors.NewCollisionsClientInterceptors() + } + + endpoint, payload, err := cli2.ParseEndpoint( + scheme, + host, + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + debug, + collisionsInterceptors, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "collisions": + switch flag.Arg(1) { + case "read": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") +} + +func httpUsageExamples() string { + return cli2.UsageExamples() +} + +===== gRPC server endpoints use ===== +// New instantiates the server struct with the Collisions service endpoints. +func New(e *collisions.Endpoints2, uh goagrpc.UnaryHandler) *Server { + return &Server{ + ReadH: NewReadHandler(e.Read, uh), + } +} + +===== gRPC example server endpoints use ===== +// handleGRPCServer starts configures and starts a gRPC server on the given +// URL. It shuts down the server if any error is received in the error channel. +func handleGRPCServer(ctx context.Context, u *url.URL, collisionsEndpoints *collisions.Endpoints2, wg *sync.WaitGroup, errc chan error, dbg bool) { + + // Wrap the endpoints with the transport specific layers. The generated + // server packages contains code generated from the design which maps + // the service input and output data structures to gRPC requests and + // responses. + var ( + collisionsServer *collisionssvr.Server + ) + { + collisionsServer = collisionssvr.New(collisionsEndpoints, nil) + } + + // Create interceptor which sets up the logger in each request context. + chain := grpc.ChainUnaryInterceptor(log.UnaryServerInterceptor(ctx)) + if dbg { + // Log request and response content if debug logs are enabled. + chain = grpc.ChainUnaryInterceptor(log.UnaryServerInterceptor(ctx), debug.UnaryServerInterceptor()) + } + + // Initialize gRPC server + srv := grpc.NewServer(chain) + + // Register the servers. + collisionspb.RegisterCollisionsServer(srv, collisionsServer) + log.Printf(ctx, "serving gRPC method %s", "collisions.Collisions/Read") + + // Register the server reflection service on the server. + // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. + reflection.Register(srv) + + (*wg).Add(1) + go func() { + defer (*wg).Done() + + // Start gRPC server in a separate goroutine. + go func() { + lis, err := net.Listen("tcp", u.Host) + if err != nil { + errc <- err + } + if lis == nil { + errc <- fmt.Errorf("failed to listen on %q", u.Host) + } + log.Printf(ctx, "gRPC server listening on %q", u.Host) + errc <- srv.Serve(lis) + }() + + <-ctx.Done() + log.Printf(ctx, "shutting down gRPC server at %q", u.Host) + srv.Stop() + }() +} + +===== gRPC command parser ===== +// ParseEndpoint returns the endpoint and payload as specified on the command +// line. +func ParseEndpoint( + cc *grpc.ClientConn, + collisionsInter collisions.ClientInterceptors2, + opts ...grpc.CallOption, +) (goa.Endpoint, any, error) { + var ( + collisionsFlags = flag.NewFlagSet("collisions", flag.ContinueOnError) + + collisionsReadFlags = flag.NewFlagSet("read", flag.ExitOnError) + collisionsReadMessageFlag = collisionsReadFlags.String("message", "", "") + ) + collisionsFlags.Usage = collisionsUsage + collisionsReadFlags.Usage = collisionsReadUsage + + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + return nil, nil, err + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + svcn string + svcf *flag.FlagSet + ) + { + svcn = flag.Arg(0) + switch svcn { + case "collisions": + svcf = collisionsFlags + default: + return nil, nil, fmt.Errorf("unknown service %q", svcn) + } + } + if err := svcf.Parse(flag.Args()[1:]); err != nil { + return nil, nil, err + } + + var ( + epn string + epf *flag.FlagSet + ) + { + epn = svcf.Arg(0) + switch svcn { + case "collisions": + switch epn { + case "read": + epf = collisionsReadFlags + + } + + } + } + if epf == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", svcn, epn) + } + + // Parse endpoint flags if any + if svcf.NArg() > 1 { + if err := epf.Parse(svcf.Args()[1:]); err != nil { + return nil, nil, err + } + } + + var ( + data any + endpoint goa.Endpoint + err error + ) + { + switch svcn { + case "collisions": + c := collisionsc.NewClient(cc, opts...) + switch epn { + case "read": + endpoint = c.Read() + endpoint = collisions.WrapReadClientEndpoint2(endpoint, collisionsInter) + data, err = collisionsc.BuildReadPayload(*collisionsReadMessageFlag) + } + } + } + if err != nil { + return nil, nil, err + } + + return endpoint, data, nil +} + diff --git a/http/codegen/testdata/golden/planned_union_name_collisions.go.golden b/http/codegen/testdata/golden/planned_union_name_collisions.go.golden new file mode 100644 index 0000000000..5f4a213423 --- /dev/null +++ b/http/codegen/testdata/golden/planned_union_name_collisions.go.golden @@ -0,0 +1,152 @@ +// Choice2 holds exactly one of its branch values. +type Choice2 struct { + kind Choice2Kind + Text ChoiceTextRequestBody + Count ChoiceCountRequestBody +} + +// Choice2Kind records which Choice2 branch is selected. +type Choice2Kind string + +const ( + // Choice2KindText identifies the text branch. + Choice2KindText Choice2Kind = "text" + // Choice2KindCount identifies the count branch. + Choice2KindCount Choice2Kind = "count" +) + +// Kind returns the selected branch. +func (u Choice2) Kind() Choice2Kind { + return u.kind +} + +// NewChoice2Text constructs Choice2 with the text branch set. +func NewChoice2Text(v ChoiceTextRequestBody) Choice2 { + return Choice2{ + kind: Choice2KindText, + Text: v, + } +} + +// AsText returns the value when the text branch is selected. +func (u Choice2) AsText() (_ ChoiceTextRequestBody, ok bool) { + if u.kind != Choice2KindText { + return + } + return u.Text, true +} + +// SetText selects the text branch and stores v. +func (u *Choice2) SetText(v ChoiceTextRequestBody) { + u.kind = Choice2KindText + u.Text = v +} + +// NewChoice2Count constructs Choice2 with the count branch set. +func NewChoice2Count(v ChoiceCountRequestBody) Choice2 { + return Choice2{ + kind: Choice2KindCount, + Count: v, + } +} + +// AsCount returns the value when the count branch is selected. +func (u Choice2) AsCount() (_ ChoiceCountRequestBody, ok bool) { + if u.kind != Choice2KindCount { + return + } + return u.Count, true +} + +// SetCount selects the count branch and stores v. +func (u *Choice2) SetCount(v ChoiceCountRequestBody) { + u.kind = Choice2KindCount + u.Count = v +} + +// Validate ensures exactly one valid branch is selected. +func (u Choice2) Validate() error { + switch u.kind { + case "": + return goa.InvalidEnumValueError("type", "", []any{ + string(Choice2KindText), + string(Choice2KindCount), + }) + case Choice2KindText: + return nil + case Choice2KindCount: + return nil + default: + return goa.InvalidEnumValueError("type", u.kind, []any{ + string(Choice2KindText), + string(Choice2KindCount), + }) + } +} + +// MarshalJSON marshals the union into the canonical {type,value} JSON shape. +func (u Choice2) MarshalJSON() ([]byte, error) { + if err := u.Validate(); err != nil { + return nil, err + } + var ( + value any + ) + switch u.kind { + case Choice2KindText: + value = u.Text + case Choice2KindCount: + value = u.Count + default: + return nil, fmt.Errorf("unexpected Choice2 kind %q", u.kind) + } + return json.Marshal(struct { + Type string `json:"type"` + Value any `json:"value"` + }{ + Type: string(u.kind), + Value: value, + }) +} + +// UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape. +func (u *Choice2) UnmarshalJSON(data []byte) error { + var raw struct { + Type string `json:"type"` + Value json.RawMessage `json:"value"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw.Value) == 0 { + return goa.MissingFieldError("value", "Choice2") + } + if bytes.Equal(bytes.TrimSpace(raw.Value), []byte("null")) { + return goa.InvalidFieldTypeError("value", nil, "non-null JSON value") + } + switch raw.Type { + case string(Choice2KindText): + var v ChoiceTextRequestBody + if err := json.Unmarshal(raw.Value, &v); err != nil { + return err + } + u.kind = Choice2KindText + u.Text = v + case string(Choice2KindCount): + var v ChoiceCountRequestBody + if err := json.Unmarshal(raw.Value, &v); err != nil { + return err + } + u.kind = Choice2KindCount + u.Count = v + default: + if raw.Type == "" { + return goa.MissingFieldError("type", "Choice2") + } + return goa.InvalidEnumValueError("type", raw.Type, []any{ + string(Choice2KindText), + string(Choice2KindCount), + }) + } + return nil +} diff --git a/http/codegen/testdata/golden/released_streaming_response_collection_client.go.golden b/http/codegen/testdata/golden/released_streaming_response_collection_client.go.golden new file mode 100644 index 0000000000..790a102d4e --- /dev/null +++ b/http/codegen/testdata/golden/released_streaming_response_collection_client.go.golden @@ -0,0 +1,5 @@ +// UsertypeResponseTinyCollection is the type of the +// "StreamingPayloadResultCollectionWithExplicitViewService" service +// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint HTTP +// response body. +type UsertypeResponseTinyCollection []*UsertypeResponseTiny diff --git a/http/codegen/testdata/golden/released_streaming_response_collection_server.go.golden b/http/codegen/testdata/golden/released_streaming_response_collection_server.go.golden new file mode 100644 index 0000000000..ece6314b98 --- /dev/null +++ b/http/codegen/testdata/golden/released_streaming_response_collection_server.go.golden @@ -0,0 +1,21 @@ +// UsertypeResponseTinyCollection is the type of the +// "StreamingPayloadResultCollectionWithExplicitViewService" service +// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint HTTP +// response body. +type UsertypeResponseTinyCollection []*UsertypeResponseTiny + +// NewUsertypeResponseTinyCollection builds the HTTP response body from the +// result of the "StreamingPayloadResultCollectionWithExplicitViewMethod" +// endpoint of the "StreamingPayloadResultCollectionWithExplicitViewService" +// service. +func NewUsertypeResponseTinyCollection(res streamingpayloadresultcollectionwithexplicitviewserviceviews.UsertypeCollectionView) UsertypeResponseTinyCollection { + body := make([]*UsertypeResponseTiny, len(res)) + for i, val := range res { + if val == nil { + body[i] = nil + continue + } + body[i] = marshalStreamingpayloadresultcollectionwithexplicitviewserviceviewsUsertypeViewToUsertypeResponseTiny(val) + } + return body +} diff --git a/http/codegen/testdata/golden/server-multipart-array.golden b/http/codegen/testdata/golden/server-multipart-array.golden new file mode 100644 index 0000000000..44780f3455 --- /dev/null +++ b/http/codegen/testdata/golden/server-multipart-array.golden @@ -0,0 +1,22 @@ +import ( + "mime/multipart" + + servicemultipartarraytypesvr "generated.local/gen/http/service_multipart_array_type/server" + servicemultipartarraytype "generated.local/gen/service_multipart_array_type" +) + +// ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc reads the +// multipart request body for service "ServiceMultipartArrayType" endpoint +// "MethodMultipartArrayType" into body. +func ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc(mr *multipart.Reader, body *[]*servicemultipartarraytypesvr.PayloadTypeRequestBody) error { + // Add multipart request decoder logic here + return nil +} + +// ServiceMultipartArrayTypeMethodMultipartArrayTypeEncoderFunc implements the +// multipart encoder for service "ServiceMultipartArrayType" endpoint +// "MethodMultipartArrayType". +func ServiceMultipartArrayTypeMethodMultipartArrayTypeEncoderFunc(mw *multipart.Writer, p []*servicemultipartarraytype.PayloadType) error { + // Add multipart request encoder logic here + return nil +} diff --git a/http/codegen/testdata/golden/server-multipart-map.golden b/http/codegen/testdata/golden/server-multipart-map.golden new file mode 100644 index 0000000000..31289d9360 --- /dev/null +++ b/http/codegen/testdata/golden/server-multipart-map.golden @@ -0,0 +1,19 @@ +import ( + "mime/multipart" +) + +// ServiceMultipartMapTypeMethodMultipartMapTypeDecoderFunc reads the multipart +// request body for service "ServiceMultipartMapType" endpoint +// "MethodMultipartMapType" into body. +func ServiceMultipartMapTypeMethodMultipartMapTypeDecoderFunc(mr *multipart.Reader, body *map[string]int) error { + // Add multipart request decoder logic here + return nil +} + +// ServiceMultipartMapTypeMethodMultipartMapTypeEncoderFunc implements the +// multipart encoder for service "ServiceMultipartMapType" endpoint +// "MethodMultipartMapType". +func ServiceMultipartMapTypeMethodMultipartMapTypeEncoderFunc(mw *multipart.Writer, p map[string]int) error { + // Add multipart request encoder logic here + return nil +} diff --git a/http/codegen/testdata/golden/server-multipart-object.golden b/http/codegen/testdata/golden/server-multipart-object.golden new file mode 100644 index 0000000000..778678dd79 --- /dev/null +++ b/http/codegen/testdata/golden/server-multipart-object.golden @@ -0,0 +1,22 @@ +import ( + "mime/multipart" + + servicemultipartvalidationsvr "generated.local/gen/http/service_multipart_validation/server" + servicemultipartvalidation "generated.local/gen/service_multipart_validation" +) + +// ServiceMultipartValidationMethodMultipartValidationDecoderFunc reads the +// multipart request body for service "ServiceMultipartValidation" endpoint +// "MethodMultipartValidation" into body. +func ServiceMultipartValidationMethodMultipartValidationDecoderFunc(mr *multipart.Reader, body *servicemultipartvalidationsvr.MethodMultipartValidationRequestBody) error { + // Add multipart request decoder logic here + return nil +} + +// ServiceMultipartValidationMethodMultipartValidationEncoderFunc implements +// the multipart encoder for service "ServiceMultipartValidation" endpoint +// "MethodMultipartValidation". +func ServiceMultipartValidationMethodMultipartValidationEncoderFunc(mw *multipart.Writer, p *servicemultipartvalidation.MethodMultipartValidationPayload) error { + // Add multipart request encoder logic here + return nil +} diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden index 2c63d024f3..6aee8eac3d 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden @@ -21,7 +21,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequiredRequest(mux goahttp.Muxer, decode } for _, e := range body { if e != nil { - if err2 := ValidatePayloadTypeRequestBody(e); err2 != nil { + if err2 := validatePayloadTypeRequestBody(e, "body[*]"); err2 != nil { err = goa.MergeErrors(err, err2) } } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden index dfa38dfb4f..49a33dbde9 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden @@ -24,7 +24,7 @@ func DecodeMethodBodyPrimitiveArrayUserValidateRequest(mux goahttp.Muxer, decode } for _, e := range body { if e != nil { - if err2 := ValidatePayloadTypeRequestBody(e); err2 != nil { + if err2 := validatePayloadTypeRequestBody(e, "body[*]"); err2 != nil { err = goa.MergeErrors(err, err2) } } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-required-primitive-arrays.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-required-primitive-arrays.go.golden new file mode 100644 index 0000000000..191f32022d --- /dev/null +++ b/http/codegen/testdata/golden/server_decode_decode-body-required-primitive-arrays.go.golden @@ -0,0 +1,29 @@ +// DecodeStoreRequest returns a decoder for requests sent to the RequiredArrays +// Store endpoint. +func DecodeStoreRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*requiredarrays.StorePayload, error) { + return func(r *http.Request) (*requiredarrays.StorePayload, error) { + var payload *requiredarrays.StorePayload + var ( + body StoreRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateStoreRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewStorePayload(&body) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden index 649c70dede..431e5d354d 100644 --- a/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden @@ -1,13 +1,13 @@ -// marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextenderResponseBody +// marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextenderResponseBodyOptional // builds a value of type *ImmediatechildextenderResponseBody from a value of // type *servicedeepuserviews.ImmediatechildextenderView. -func marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextenderResponseBody(v *servicedeepuserviews.ImmediatechildextenderView) *ImmediatechildextenderResponseBody { +func marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextenderResponseBodyOptional(v *servicedeepuserviews.ImmediatechildextenderView) *ImmediatechildextenderResponseBody { if v == nil { return nil } res := &ImmediatechildextenderResponseBody{} if v.DeepChild != nil { - res.DeepChild = marshalServicedeepuserviewsDeepchildViewToDeepchildResponseBody(v.DeepChild) + res.DeepChild = marshalServicedeepuserviewsDeepchildViewToDeepchildResponseBodyOptional(v.DeepChild) } return res diff --git a/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-array.go.golden b/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-array.go.golden index b98e11f887..0036765930 100644 --- a/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-array.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-array.go.golden @@ -12,34 +12,24 @@ func DecodeMapQueryPrimitiveArrayRequest(mux goahttp.Muxer, decoder func(*http.R if len(queryRaw) == 0 { err = goa.MergeErrors(err, goa.MissingFieldError("query", "query string")) } + if query == nil { + query = make(map[string][]uint) + } for keyRaw, valRaw := range queryRaw { - if strings.HasPrefix(keyRaw, "query[") { - if query == nil { - query = make(map[string][]uint) - } - var keya string - { - openIdx := strings.IndexRune(keyRaw, '[') - closeIdx := strings.IndexRune(keyRaw, ']') - if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { - err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) - } else { - keya = keyRaw[openIdx+1 : closeIdx] - } - } - var val []uint - { - val = make([]uint, len(valRaw)) - for i, rv := range valRaw { - v, err2 := strconv.ParseUint(rv, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", valRaw, "array of unsigned integers")) - } - val[i] = uint(v) + var key string + key = keyRaw + var val []uint + { + val = make([]uint, len(valRaw)) + for i, rv := range valRaw { + v, err2 := strconv.ParseUint(rv, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", valRaw, "array of unsigned integers")) } + val[i] = uint(v) } - query[keya] = val } + query[key] = val } } if err != nil { diff --git a/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-primitive.go.golden b/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-primitive.go.golden index 52b194560c..49e38038bb 100644 --- a/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-primitive.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-primitive.go.golden @@ -12,23 +12,13 @@ func DecodeMapQueryPrimitivePrimitiveRequest(mux goahttp.Muxer, decoder func(*ht if len(queryRaw) == 0 { err = goa.MergeErrors(err, goa.MissingFieldError("query", "query string")) } + if query == nil { + query = make(map[string]string) + } for keyRaw, valRaw := range queryRaw { - if strings.HasPrefix(keyRaw, "query[") { - if query == nil { - query = make(map[string]string) - } - var keya string - { - openIdx := strings.IndexRune(keyRaw, '[') - closeIdx := strings.IndexRune(keyRaw, ']') - if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { - err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) - } else { - keya = keyRaw[openIdx+1 : closeIdx] - } - } - query[keya] = valRaw[0] - } + var key string + key = keyRaw + query[key] = valRaw[0] } } if err != nil { diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-array-type.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-array-type.go.golden index 44258c1cb8..89e4cfc3b4 100644 --- a/http/codegen/testdata/golden/server_decode_decode-multipart-body-array-type.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-array-type.go.golden @@ -3,13 +3,32 @@ func DecodeMethodMultipartArrayTypeRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) ([]*servicemultipartarraytype.PayloadType, error) { return func(r *http.Request) ([]*servicemultipartarraytype.PayloadType, error) { var payload []*servicemultipartarraytype.PayloadType - if err := decoder(r).Decode(&payload); err != nil { + var ( + body []*PayloadTypeRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } var gerr *goa.ServiceError if errors.As(err, &gerr) { return payload, gerr } return payload, goa.DecodePayloadError(err.Error()) } + for _, e := range body { + if e != nil { + if err2 := validatePayloadTypeRequestBody(e, "body[*]"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + if err != nil { + return payload, err + } + payload = NewMethodMultipartArrayTypePayloadType(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-map-type.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-map-type.go.golden index 37f333f73d..4cc86de67b 100644 --- a/http/codegen/testdata/golden/server_decode_decode-multipart-body-map-type.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-map-type.go.golden @@ -3,13 +3,22 @@ func DecodeMethodMultipartMapTypeRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (map[string]int, error) { return func(r *http.Request) (map[string]int, error) { var payload map[string]int - if err := decoder(r).Decode(&payload); err != nil { + var ( + body map[string]int + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } var gerr *goa.ServiceError if errors.As(err, &gerr) { return payload, gerr } return payload, goa.DecodePayloadError(err.Error()) } + payload = body return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-primitive.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-primitive.go.golden index d763025157..44e8ad610c 100644 --- a/http/codegen/testdata/golden/server_decode_decode-multipart-body-primitive.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-primitive.go.golden @@ -3,13 +3,22 @@ func DecodeMethodMultipartPrimitiveRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (string, error) { return func(r *http.Request) (string, error) { var payload string - if err := decoder(r).Decode(&payload); err != nil { + var ( + body string + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } var gerr *goa.ServiceError if errors.As(err, &gerr) { return payload, gerr } return payload, goa.DecodePayloadError(err.Error()) } + payload = body return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-user-type.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-user-type.go.golden index 032f70ee31..d431496263 100644 --- a/http/codegen/testdata/golden/server_decode_decode-multipart-body-user-type.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-user-type.go.golden @@ -3,13 +3,26 @@ func DecodeMethodMultipartUserTypeRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*servicemultipartusertype.MethodMultipartUserTypePayload, error) { return func(r *http.Request) (*servicemultipartusertype.MethodMultipartUserTypePayload, error) { var payload *servicemultipartusertype.MethodMultipartUserTypePayload - if err := decoder(r).Decode(&payload); err != nil { + var ( + body MethodMultipartUserTypeRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } var gerr *goa.ServiceError if errors.As(err, &gerr) { return payload, gerr } return payload, goa.DecodePayloadError(err.Error()) } + err = ValidateMethodMultipartUserTypeRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewMethodMultipartUserTypePayload(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-validation.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-validation.go.golden new file mode 100644 index 0000000000..87397a5509 --- /dev/null +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-validation.go.golden @@ -0,0 +1,29 @@ +// DecodeMethodMultipartValidationRequest returns a decoder for requests sent +// to the ServiceMultipartValidation MethodMultipartValidation endpoint. +func DecodeMethodMultipartValidationRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*servicemultipartvalidation.MethodMultipartValidationPayload, error) { + return func(r *http.Request) (*servicemultipartvalidation.MethodMultipartValidationPayload, error) { + var payload *servicemultipartvalidation.MethodMultipartValidationPayload + var ( + body MethodMultipartValidationRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateMethodMultipartValidationRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewMethodMultipartValidationPayload(&body) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-with-param.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-with-param.go.golden new file mode 100644 index 0000000000..043170b1f3 --- /dev/null +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-with-param.go.golden @@ -0,0 +1,65 @@ +// DecodeMethodMultipartWithParamRequest returns a decoder for requests sent to +// the ServiceMultipartWithParam MethodMultipartWithParam endpoint. +func DecodeMethodMultipartWithParamRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*servicemultipartwithparam.PayloadType, error) { + return func(r *http.Request) (*servicemultipartwithparam.PayloadType, error) { + var payload *servicemultipartwithparam.PayloadType + var ( + body MethodMultipartWithParamRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateMethodMultipartWithParamRequestBody(&body) + if err != nil { + return payload, err + } + + var ( + c2 map[int][]string + ) + { + c2Raw := r.URL.Query() + if len(c2Raw) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("c", "query string")) + } + for keyRaw, valRaw := range c2Raw { + if strings.HasPrefix(keyRaw, "c[") { + if c2 == nil { + c2 = make(map[int][]string) + } + var keya int + { + openIdx := strings.IndexRune(keyRaw, '[') + closeIdx := strings.IndexRune(keyRaw, ']') + if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { + err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) + } else { + keyaRaw := keyRaw[openIdx+1 : closeIdx] + v, err2 := strconv.ParseInt(keyaRaw, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", keyaRaw, "integer")) + } + keya = int(v) + } + } + c2[keya] = valRaw + } + } + } + if err != nil { + return payload, err + } + payload = NewMethodMultipartWithParamPayloadType(&body, c2) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-with-params-and-headers.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-with-params-and-headers.go.golden new file mode 100644 index 0000000000..2de7af9d40 --- /dev/null +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-with-params-and-headers.go.golden @@ -0,0 +1,79 @@ +// DecodeMethodMultipartWithParamsAndHeadersRequest returns a decoder for +// requests sent to the ServiceMultipartWithParamsAndHeaders +// MethodMultipartWithParamsAndHeaders endpoint. +func DecodeMethodMultipartWithParamsAndHeadersRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*servicemultipartwithparamsandheaders.PayloadType, error) { + return func(r *http.Request) (*servicemultipartwithparamsandheaders.PayloadType, error) { + var payload *servicemultipartwithparamsandheaders.PayloadType + var ( + body MethodMultipartWithParamsAndHeadersRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateMethodMultipartWithParamsAndHeadersRequestBody(&body) + if err != nil { + return payload, err + } + + var ( + a string + c2 map[int][]string + b *string + + params = mux.Vars(r) + ) + a = params["a"] + err = goa.MergeErrors(err, goa.ValidatePattern("a", a, "patterna")) + { + c2Raw := r.URL.Query() + if len(c2Raw) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("c", "query string")) + } + for keyRaw, valRaw := range c2Raw { + if strings.HasPrefix(keyRaw, "c[") { + if c2 == nil { + c2 = make(map[int][]string) + } + var keya int + { + openIdx := strings.IndexRune(keyRaw, '[') + closeIdx := strings.IndexRune(keyRaw, ']') + if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { + err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) + } else { + keyaRaw := keyRaw[openIdx+1 : closeIdx] + v, err2 := strconv.ParseInt(keyaRaw, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", keyaRaw, "integer")) + } + keya = int(v) + } + } + c2[keya] = valRaw + } + } + } + bRaw := r.Header.Get("Authorization") + if bRaw != "" { + b = &bRaw + } + if b != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("b", *b, "patternb")) + } + if err != nil { + return payload, err + } + payload = NewMethodMultipartWithParamsAndHeadersPayloadType(&body, a, c2, b) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/server_encode_explicit-body-result-collection.go.golden b/http/codegen/testdata/golden/server_encode_explicit-body-result-collection.go.golden index 43ba6e30a8..b681ede57e 100644 --- a/http/codegen/testdata/golden/server_encode_explicit-body-result-collection.go.golden +++ b/http/codegen/testdata/golden/server_encode_explicit-body-result-collection.go.golden @@ -5,7 +5,7 @@ func EncodeMethodExplicitBodyResultCollectionResponse(encoder func(context.Conte return func(ctx context.Context, w http.ResponseWriter, v any) error { res, _ := v.(*serviceexplicitbodyresultcollection.MethodExplicitBodyResultCollectionResult) enc := encoder(ctx, w) - body := NewResulttypeCollection(res) + body := NewResulttypeResponseCollection(res) w.WriteHeader(http.StatusOK) return enc.Encode(body) } diff --git a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden index c79024ee92..9bab86ab57 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden @@ -1,6 +1,6 @@ -// unmarshalFooRequestBodyToFooFoo builds a value of type *foo.Foo from a value -// of type *FooRequestBody. -func unmarshalFooRequestBodyToFooFoo(v *FooRequestBody) *foo.Foo { +// unmarshalFooRequestBodyToFooFooOptional builds a value of type *foo.Foo from +// a value of type *FooRequestBody. +func unmarshalFooRequestBodyToFooFooOptional(v *FooRequestBody) *foo.Foo { if v == nil { return nil } diff --git a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden index 4f3e2bf6df..ce16450811 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden @@ -1,6 +1,6 @@ -// marshalFooFooToFooResponseBody builds a value of type *FooResponseBody from -// a value of type *foo.Foo. -func marshalFooFooToFooResponseBody(v *foo.Foo) *FooResponseBody { +// marshalFooFooToFooResponseBodyOptional builds a value of type +// *FooResponseBody from a value of type *foo.Foo. +func marshalFooFooToFooResponseBodyOptional(v *foo.Foo) *FooResponseBody { if v == nil { return nil } diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden index ca22a60a55..0027baa62c 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden @@ -1,12 +1,12 @@ -// unmarshalExtensionRequestBodyToFooserviceExtension builds a value of type -// *fooservice.Extension from a value of type *ExtensionRequestBody. -func unmarshalExtensionRequestBodyToFooserviceExtension(v *ExtensionRequestBody) *fooservice.Extension { +// unmarshalExtensionRequestBodyToFooserviceExtensionOptional builds a value of +// type *fooservice.Extension from a value of type *ExtensionRequestBody. +func unmarshalExtensionRequestBodyToFooserviceExtensionOptional(v *ExtensionRequestBody) *fooservice.Extension { if v == nil { return nil } res := &fooservice.Extension{} if v.Bar != nil { - res.Bar = unmarshalBarRequestBodyToFooserviceBar(v.Bar) + res.Bar = unmarshalBarRequestBodyToFooserviceBarOptional(v.Bar) } return res diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden index 32ec2f62e4..d035f5011a 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden @@ -1,6 +1,6 @@ -// unmarshalBarRequestBodyToFooserviceBar builds a value of type +// unmarshalBarRequestBodyToFooserviceBarOptional builds a value of type // *fooservice.Bar from a value of type *BarRequestBody. -func unmarshalBarRequestBodyToFooserviceBar(v *BarRequestBody) *fooservice.Bar { +func unmarshalBarRequestBodyToFooserviceBarOptional(v *BarRequestBody) *fooservice.Bar { if v == nil { return nil } diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden index 0317d61a1e..4029f08510 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section2.go.golden @@ -3,7 +3,7 @@ func marshalFooserviceResultTypeToResultTypeResponse(v *fooservice.ResultType) *ResultTypeResponse { res := &ResultTypeResponse{} if v.Extension != nil { - res.Extension = marshalFooserviceExtensionToExtensionResponse(v.Extension) + res.Extension = marshalFooserviceExtensionToExtensionResponseOptional(v.Extension) } return res diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden index 730ae35211..85209b8a5a 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden @@ -1,12 +1,12 @@ -// marshalFooserviceExtensionToExtensionResponse builds a value of type +// marshalFooserviceExtensionToExtensionResponseOptional builds a value of type // *ExtensionResponse from a value of type *fooservice.Extension. -func marshalFooserviceExtensionToExtensionResponse(v *fooservice.Extension) *ExtensionResponse { +func marshalFooserviceExtensionToExtensionResponseOptional(v *fooservice.Extension) *ExtensionResponse { if v == nil { return nil } res := &ExtensionResponse{} if v.Bar != nil { - res.Bar = marshalFooserviceBarToBarResponse(v.Bar) + res.Bar = marshalFooserviceBarToBarResponseOptional(v.Bar) } return res diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden index 97c3aeb895..c6dca39665 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden @@ -1,6 +1,6 @@ -// marshalFooserviceBarToBarResponse builds a value of type *BarResponse from a -// value of type *fooservice.Bar. -func marshalFooserviceBarToBarResponse(v *fooservice.Bar) *BarResponse { +// marshalFooserviceBarToBarResponseOptional builds a value of type +// *BarResponse from a value of type *fooservice.Bar. +func marshalFooserviceBarToBarResponseOptional(v *fooservice.Bar) *BarResponse { if v == nil { return nil } diff --git a/http/codegen/testdata/golden/server_extensions_endpoint_helper.go.golden b/http/codegen/testdata/golden/server_extensions_endpoint_helper.go.golden new file mode 100644 index 0000000000..31864b5c21 --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_endpoint_helper.go.golden @@ -0,0 +1,12 @@ +// MountReadHandler configures the mux to serve the "Files" service "Read" +// endpoint. +func MountReadHandler(mux goahttp.Muxer, h http.Handler) { + h = First(Second(wrapEndpoint(h))) + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("GET", "/items/{id}", f) +} diff --git a/http/codegen/testdata/golden/server_extensions_escaping.go.golden b/http/codegen/testdata/golden/server_extensions_escaping.go.golden new file mode 100644 index 0000000000..cb391c4ced --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_escaping.go.golden @@ -0,0 +1,22 @@ +// New instantiates HTTP handlers for all the Escape service endpoints using +// the provided encoder and decoder. The handlers are mounted on the given mux +// using the HTTP verb and path defined in the design. errhandler is called +// whenever a response fails to be encoded. formatter is used to format errors +// returned by the service methods prior to encoding. Both errhandler and +// formatter are optional and can be nil. +func New( + e *escape.Endpoints, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) *Server { + return &Server{ + Mounts: []*MountPoint{ + {"Ping", "GET", "/"}, + {"Quoted \"method\"\nnext", "CUSTOM\\VERB", "/quoted/\"value\"\\next\nline"}, + }, + Ping: NewPingHandler(e.Ping, mux, decoder, encoder, errhandler, formatter), + } +} diff --git a/http/codegen/testdata/golden/server_extensions_file_helper.go.golden b/http/codegen/testdata/golden/server_extensions_file_helper.go.golden new file mode 100644 index 0000000000..98ca985187 --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_file_helper.go.golden @@ -0,0 +1,6 @@ +// MountAssets configures the mux to serve GET request made to "/assets". +func MountAssets(mux goahttp.Muxer, h http.Handler) { + h = First(Second(h)) + mux.Handle("GET", "/assets/", h.ServeHTTP) + mux.Handle("GET", "/assets/{*path}", h.ServeHTTP) +} diff --git a/http/codegen/testdata/golden/server_extensions_init.go.golden b/http/codegen/testdata/golden/server_extensions_init.go.golden new file mode 100644 index 0000000000..a0711e108c --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_init.go.golden @@ -0,0 +1,37 @@ +// New instantiates HTTP handlers for all the Files service endpoints using the +// provided encoder and decoder. The handlers are mounted on the given mux +// using the HTTP verb and path defined in the design. errhandler is called +// whenever a response fails to be encoded. formatter is used to format errors +// returned by the service methods prior to encoding. Both errhandler and +// formatter are optional and can be nil. +func New( + e *files.Endpoints, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, + fileSystemAssets http.FileSystem, + fileSystemOldHTML http.FileSystem, +) *Server { + if fileSystemAssets == nil { + fileSystemAssets = http.Dir(".") + } + fileSystemAssets = appendPrefix(fileSystemAssets, "/assets") + if fileSystemOldHTML == nil { + fileSystemOldHTML = http.Dir(".") + } + fileSystemOldHTML = appendPrefix(fileSystemOldHTML, "/") + return &Server{ + Mounts: []*MountPoint{ + {"Read", "GET", "/items/{id}"}, + {"Serve assets", "GET", "/assets"}, + {"Serve old.html", "GET", "/old"}, + {"Preflight item", "OPTIONS", "/items/{id}"}, + {"Preflight assets", "OPTIONS", "/assets/{*path}"}, + }, + Read: NewReadHandler(e.Read, mux, decoder, encoder, errhandler, formatter), + Assets: http.FileServer(fileSystemAssets), + OldHTML: http.FileServer(fileSystemOldHTML), + } +} diff --git a/http/codegen/testdata/golden/server_extensions_mount.go.golden b/http/codegen/testdata/golden/server_extensions_mount.go.golden new file mode 100644 index 0000000000..20098b2236 --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_mount.go.golden @@ -0,0 +1,14 @@ +// Mount configures the mux to serve the Files endpoints. +func Mount(mux goahttp.Muxer, h *Server) { + MountReadHandler(mux, h.Read) + MountAssets(mux, http.StripPrefix("/assets", h.Assets)) + MountOldHTML(mux, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/new", http.StatusMovedPermanently) + })) + MountPreflight(mux) +} + +// Mount configures the mux to serve the Files endpoints. +func (s *Server) Mount(mux goahttp.Muxer) { + Mount(mux, s) +} diff --git a/http/codegen/testdata/golden/server_extensions_redirect_helper.go.golden b/http/codegen/testdata/golden/server_extensions_redirect_helper.go.golden new file mode 100644 index 0000000000..a4d72ff7ba --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_redirect_helper.go.golden @@ -0,0 +1,5 @@ +// MountOldHTML configures the mux to serve GET request made to "/old". +func MountOldHTML(mux goahttp.Muxer, h http.Handler) { + h = First(Second(h)) + mux.Handle("GET", "/old", h.ServeHTTP) +} diff --git a/http/codegen/testdata/golden/server_multipart_multipart-body-array-type.go.golden b/http/codegen/testdata/golden/server_multipart_multipart-body-array-type.go.golden index 3d2bf2b323..1f2519773e 100644 --- a/http/codegen/testdata/golden/server_multipart_multipart-body-array-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_multipart-body-array-type.go.golden @@ -1,4 +1,4 @@ // ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc is the type to // decode multipart request for the "ServiceMultipartArrayType" service // "MethodMultipartArrayType" endpoint. -type ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc func(*multipart.Reader, *[]*servicemultipartarraytype.PayloadType) error +type ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc func(*multipart.Reader, *[]*PayloadTypeRequestBody) error diff --git a/http/codegen/testdata/golden/server_multipart_multipart-body-user-type.go.golden b/http/codegen/testdata/golden/server_multipart_multipart-body-user-type.go.golden index d7dce056e7..83adb6442b 100644 --- a/http/codegen/testdata/golden/server_multipart_multipart-body-user-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_multipart-body-user-type.go.golden @@ -1,4 +1,4 @@ // ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc is the type to // decode multipart request for the "ServiceMultipartUserType" service // "MethodMultipartUserType" endpoint. -type ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc func(*multipart.Reader, **servicemultipartusertype.MethodMultipartUserTypePayload) error +type ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc func(*multipart.Reader, *MethodMultipartUserTypeRequestBody) error diff --git a/http/codegen/testdata/golden/server_multipart_multipart-body-validation.go.golden b/http/codegen/testdata/golden/server_multipart_multipart-body-validation.go.golden new file mode 100644 index 0000000000..3aaa0eebcf --- /dev/null +++ b/http/codegen/testdata/golden/server_multipart_multipart-body-validation.go.golden @@ -0,0 +1,4 @@ +// ServiceMultipartValidationMethodMultipartValidationDecoderFunc is the type +// to decode multipart request for the "ServiceMultipartValidation" service +// "MethodMultipartValidation" endpoint. +type ServiceMultipartValidationMethodMultipartValidationDecoderFunc func(*multipart.Reader, *MethodMultipartValidationRequestBody) error diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-array-type.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-array-type.go.golden index 80d15bab46..5d1eca1a42 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-body-array-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-array-type.go.golden @@ -1,15 +1,15 @@ // NewServiceMultipartArrayTypeMethodMultipartArrayTypeDecoder returns a // decoder to decode the multipart request for the "ServiceMultipartArrayType" // service "MethodMultipartArrayType" endpoint. -func NewServiceMultipartArrayTypeMethodMultipartArrayTypeDecoder(mux goahttp.Muxer, serviceMultipartArrayTypeMethodMultipartArrayTypeDecoderFn ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartArrayTypeMethodMultipartArrayTypeDecoder(_ goahttp.Muxer, serviceMultipartArrayTypeMethodMultipartArrayTypeDecoderFn ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(*[]*servicemultipartarraytype.PayloadType) - if err := serviceMultipartArrayTypeMethodMultipartArrayTypeDecoderFn(mr, p); err != nil { + body := v.(*[]*PayloadTypeRequestBody) + if err := serviceMultipartArrayTypeMethodMultipartArrayTypeDecoderFn(mr, body); err != nil { return err } return nil diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-map-type.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-map-type.go.golden index 604f597f90..d3540d9071 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-body-map-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-map-type.go.golden @@ -1,15 +1,15 @@ // NewServiceMultipartMapTypeMethodMultipartMapTypeDecoder returns a decoder to // decode the multipart request for the "ServiceMultipartMapType" service // "MethodMultipartMapType" endpoint. -func NewServiceMultipartMapTypeMethodMultipartMapTypeDecoder(mux goahttp.Muxer, serviceMultipartMapTypeMethodMultipartMapTypeDecoderFn ServiceMultipartMapTypeMethodMultipartMapTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartMapTypeMethodMultipartMapTypeDecoder(_ goahttp.Muxer, serviceMultipartMapTypeMethodMultipartMapTypeDecoderFn ServiceMultipartMapTypeMethodMultipartMapTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(*map[string]int) - if err := serviceMultipartMapTypeMethodMultipartMapTypeDecoderFn(mr, p); err != nil { + body := v.(*map[string]int) + if err := serviceMultipartMapTypeMethodMultipartMapTypeDecoderFn(mr, body); err != nil { return err } return nil diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-primitive.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-primitive.go.golden index 1b90418bbc..f6774d6fec 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-body-primitive.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-primitive.go.golden @@ -1,15 +1,15 @@ // NewServiceMultipartPrimitiveMethodMultipartPrimitiveDecoder returns a // decoder to decode the multipart request for the "ServiceMultipartPrimitive" // service "MethodMultipartPrimitive" endpoint. -func NewServiceMultipartPrimitiveMethodMultipartPrimitiveDecoder(mux goahttp.Muxer, serviceMultipartPrimitiveMethodMultipartPrimitiveDecoderFn ServiceMultipartPrimitiveMethodMultipartPrimitiveDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartPrimitiveMethodMultipartPrimitiveDecoder(_ goahttp.Muxer, serviceMultipartPrimitiveMethodMultipartPrimitiveDecoderFn ServiceMultipartPrimitiveMethodMultipartPrimitiveDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(*string) - if err := serviceMultipartPrimitiveMethodMultipartPrimitiveDecoderFn(mr, p); err != nil { + body := v.(*string) + if err := serviceMultipartPrimitiveMethodMultipartPrimitiveDecoderFn(mr, body); err != nil { return err } return nil diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-user-type.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-user-type.go.golden index 55e5dee3f3..cc61211248 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-body-user-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-user-type.go.golden @@ -1,15 +1,15 @@ // NewServiceMultipartUserTypeMethodMultipartUserTypeDecoder returns a decoder // to decode the multipart request for the "ServiceMultipartUserType" service // "MethodMultipartUserType" endpoint. -func NewServiceMultipartUserTypeMethodMultipartUserTypeDecoder(mux goahttp.Muxer, serviceMultipartUserTypeMethodMultipartUserTypeDecoderFn ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartUserTypeMethodMultipartUserTypeDecoder(_ goahttp.Muxer, serviceMultipartUserTypeMethodMultipartUserTypeDecoderFn ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(**servicemultipartusertype.MethodMultipartUserTypePayload) - if err := serviceMultipartUserTypeMethodMultipartUserTypeDecoderFn(mr, p); err != nil { + body := v.(*MethodMultipartUserTypeRequestBody) + if err := serviceMultipartUserTypeMethodMultipartUserTypeDecoderFn(mr, body); err != nil { return err } return nil diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-validation.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-validation.go.golden new file mode 100644 index 0000000000..c1bdd3f3d4 --- /dev/null +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-validation.go.golden @@ -0,0 +1,18 @@ +// NewServiceMultipartValidationMethodMultipartValidationDecoder returns a +// decoder to decode the multipart request for the "ServiceMultipartValidation" +// service "MethodMultipartValidation" endpoint. +func NewServiceMultipartValidationMethodMultipartValidationDecoder(_ goahttp.Muxer, serviceMultipartValidationMethodMultipartValidationDecoderFn ServiceMultipartValidationMethodMultipartValidationDecoderFunc) func(r *http.Request) goahttp.Decoder { + return func(r *http.Request) goahttp.Decoder { + return goahttp.EncodingFunc(func(v any) error { + mr, merr := r.MultipartReader() + if merr != nil { + return merr + } + body := v.(*MethodMultipartValidationRequestBody) + if err := serviceMultipartValidationMethodMultipartValidationDecoderFn(mr, body); err != nil { + return err + } + return nil + }) + } +} diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-with-param.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-with-param.go.golden index 673a61e26b..4a5a39c414 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-with-param.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-with-param.go.golden @@ -1,55 +1,17 @@ // NewServiceMultipartWithParamMethodMultipartWithParamDecoder returns a // decoder to decode the multipart request for the "ServiceMultipartWithParam" // service "MethodMultipartWithParam" endpoint. -func NewServiceMultipartWithParamMethodMultipartWithParamDecoder(mux goahttp.Muxer, serviceMultipartWithParamMethodMultipartWithParamDecoderFn ServiceMultipartWithParamMethodMultipartWithParamDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartWithParamMethodMultipartWithParamDecoder(_ goahttp.Muxer, serviceMultipartWithParamMethodMultipartWithParamDecoderFn ServiceMultipartWithParamMethodMultipartWithParamDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(**servicemultipartwithparam.PayloadType) - if err := serviceMultipartWithParamMethodMultipartWithParamDecoderFn(mr, p); err != nil { + body := v.(*MethodMultipartWithParamRequestBody) + if err := serviceMultipartWithParamMethodMultipartWithParamDecoderFn(mr, body); err != nil { return err } - - var ( - c2 map[int][]string - err error - ) - { - c2Raw := r.URL.Query() - if len(c2Raw) == 0 { - err = goa.MergeErrors(err, goa.MissingFieldError("c", "query string")) - } - for keyRaw, valRaw := range c2Raw { - if strings.HasPrefix(keyRaw, "c[") { - if c2 == nil { - c2 = make(map[int][]string) - } - var keya int - { - openIdx := strings.IndexRune(keyRaw, '[') - closeIdx := strings.IndexRune(keyRaw, ']') - if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { - err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) - } else { - keyaRaw := keyRaw[openIdx+1 : closeIdx] - v, err2 := strconv.ParseInt(keyaRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", keyaRaw, "integer")) - } - keya = int(v) - } - } - c2[keya] = valRaw - } - } - } - if err != nil { - return err - } - (*p).C = c2 return nil }) } diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-with-params-and-headers.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-with-params-and-headers.go.golden index 0fae06d72e..804bfb47a1 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-with-params-and-headers.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-with-params-and-headers.go.golden @@ -2,69 +2,17 @@ // returns a decoder to decode the multipart request for the // "ServiceMultipartWithParamsAndHeaders" service // "MethodMultipartWithParamsAndHeaders" endpoint. -func NewServiceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoder(mux goahttp.Muxer, serviceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFn ServiceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoder(_ goahttp.Muxer, serviceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFn ServiceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(**servicemultipartwithparamsandheaders.PayloadType) - if err := serviceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFn(mr, p); err != nil { + body := v.(*MethodMultipartWithParamsAndHeadersRequestBody) + if err := serviceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFn(mr, body); err != nil { return err } - var ( - a string - c2 map[int][]string - b *string - err error - - params = mux.Vars(r) - ) - a = params["a"] - err = goa.MergeErrors(err, goa.ValidatePattern("a", a, "patterna")) - { - c2Raw := r.URL.Query() - if len(c2Raw) == 0 { - err = goa.MergeErrors(err, goa.MissingFieldError("c", "query string")) - } - for keyRaw, valRaw := range c2Raw { - if strings.HasPrefix(keyRaw, "c[") { - if c2 == nil { - c2 = make(map[int][]string) - } - var keya int - { - openIdx := strings.IndexRune(keyRaw, '[') - closeIdx := strings.IndexRune(keyRaw, ']') - if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { - err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) - } else { - keyaRaw := keyRaw[openIdx+1 : closeIdx] - v, err2 := strconv.ParseInt(keyaRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", keyaRaw, "integer")) - } - keya = int(v) - } - } - c2[keya] = valRaw - } - } - } - bRaw := r.Header.Get("Authorization") - if bRaw != "" { - b = &bRaw - } - if b != nil { - err = goa.MergeErrors(err, goa.ValidatePattern("b", *b, "patternb")) - } - if err != nil { - return err - } - (*p).A = a - (*p).C = c2 - (*p).B = b return nil }) } diff --git a/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden index a92096c3cf..b2152d20f1 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden @@ -3,7 +3,7 @@ func NewMethodBodyInlineMapUserMapKeyTypeElemType(body map[*KeyTypeRequestBody]*ElemTypeRequestBody) map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType { v := make(map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, len(body)) for key, val := range body { - tk := unmarshalKeyTypeRequestBodyToServicebodyinlinemapuserKeyType(val) + tk := unmarshalKeyTypeRequestBodyToServicebodyinlinemapuserKeyType(key) if val == nil { v[tk] = nil continue diff --git a/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden b/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden index 0217998a99..b4f87bd9b8 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-query-user-union.go.golden @@ -3,7 +3,7 @@ func NewMethodBodyQueryUserUnionPayloadType(body *MethodBodyQueryUserUnionRequestBody, b *string) *servicebodyqueryuserunion.PayloadType { v := &servicebodyqueryuserunion.PayloadType{} if body.A != nil { - v.A = unmarshalUnionRequestBodyToServicebodyqueryuserunionUnion(body.A) + v.A = unmarshalUnionRequestBodyToServicebodyqueryuserunionUnionOptional(body.A) } v.B = b diff --git a/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden b/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden index 1eef27f0b2..9a92393ca1 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-user-inner-default.go.golden @@ -4,7 +4,7 @@ func NewMethodBodyUserInnerDefaultPayloadType(body *MethodBodyUserInnerDefaultRequestBody) *servicebodyuserinnerdefault.PayloadType { v := &servicebodyuserinnerdefault.PayloadType{} if body.Inner != nil { - v.Inner = unmarshalInnerTypeRequestBodyToServicebodyuserinnerdefaultInnerType(body.Inner) + v.Inner = unmarshalInnerTypeRequestBodyToServicebodyuserinnerdefaultInnerTypeOptional(body.Inner) } return v diff --git a/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden b/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden index 519355b7a7..f7fb24a8c8 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-user-inner.go.golden @@ -3,7 +3,7 @@ func NewMethodBodyUserInnerPayloadType(body *MethodBodyUserInnerRequestBody) *servicebodyuserinner.PayloadType { v := &servicebodyuserinner.PayloadType{} if body.Inner != nil { - v.Inner = unmarshalInnerTypeRequestBodyToServicebodyuserinnerInnerType(body.Inner) + v.Inner = unmarshalInnerTypeRequestBodyToServicebodyuserinnerInnerTypeOptional(body.Inner) } return v diff --git a/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden b/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden index 4f49ae0eca..1e735b8554 100644 --- a/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden +++ b/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden @@ -34,7 +34,7 @@ func NewMethodAAPayload(body *MethodARequestBody) *servicemixedpayloadinbody.APa } v.Object = unmarshalBPayloadRequestBodyToServicemixedpayloadinbodyBPayload(body.Object) if body.DupObj != nil { - v.DupObj = unmarshalBPayloadRequestBodyToServicemixedpayloadinbodyBPayload(body.DupObj) + v.DupObj = unmarshalBPayloadRequestBodyToServicemixedpayloadinbodyBPayloadOptional(body.DupObj) } return v @@ -49,23 +49,31 @@ func ValidateMethodARequestBody(body *MethodARequestBody) (err error) { err = goa.MergeErrors(err, goa.MissingFieldError("object", "body")) } if body.Object != nil { - if err2 := ValidateBPayloadRequestBody(body.Object); err2 != nil { + if err2 := validateBPayloadRequestBody(body.Object, "body.object"); err2 != nil { err = goa.MergeErrors(err, err2) } } if body.DupObj != nil { - if err2 := ValidateBPayloadRequestBody(body.DupObj); err2 != nil { + if err2 := validateBPayloadRequestBody(body.DupObj, "body.dup_obj"); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateBPayloadRequestBody runs the validations defined on -// BPayloadRequestBody +// ValidateBPayloadRequestBody runs the validations defined on BPayload func ValidateBPayloadRequestBody(body *BPayloadRequestBody) (err error) { if body.Int == nil { err = goa.MergeErrors(err, goa.MissingFieldError("int", "body")) } return } + +// validateBPayloadRequestBody checks BPayload and reports errors using the +// path supplied by its caller +func validateBPayloadRequestBody(body *BPayloadRequestBody, path string) (err error) { + if body.Int == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("int", path)) + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-multipart-validation.go.golden b/http/codegen/testdata/golden/server_types_server-multipart-validation.go.golden new file mode 100644 index 0000000000..7db641c70f --- /dev/null +++ b/http/codegen/testdata/golden/server_types_server-multipart-validation.go.golden @@ -0,0 +1,64 @@ +// MethodMultipartValidationRequestBody is the type of the +// "ServiceMultipartValidation" service "MethodMultipartValidation" endpoint +// HTTP request body. +type MethodMultipartValidationRequestBody struct { + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + Part *MultipartPartRequestBody `form:"part,omitempty" json:"part,omitempty" xml:"part,omitempty"` +} + +// MultipartPartRequestBody is used to define fields on request body types. +type MultipartPartRequestBody struct { + Code *string `form:"code,omitempty" json:"code,omitempty" xml:"code,omitempty"` +} + +// NewMethodMultipartValidationPayload builds a ServiceMultipartValidation +// service MethodMultipartValidation endpoint payload. +func NewMethodMultipartValidationPayload(body *MethodMultipartValidationRequestBody) *servicemultipartvalidation.MethodMultipartValidationPayload { + v := &servicemultipartvalidation.MethodMultipartValidationPayload{ + Name: *body.Name, + } + v.Part = unmarshalMultipartPartRequestBodyToServicemultipartvalidationMultipartPart(body.Part) + + return v +} + +// ValidateMethodMultipartValidationRequestBody runs the validations defined on +// MethodMultipartValidationRequestBody +func ValidateMethodMultipartValidationRequestBody(body *MethodMultipartValidationRequestBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.Part == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("part", "body")) + } + if body.Part != nil { + if err2 := validateMultipartPartRequestBody(body.Part, "body.part"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateMultipartPartRequestBody runs the validations defined on +// MultipartPart +func ValidateMultipartPartRequestBody(body *MultipartPartRequestBody) (err error) { + if body.Code == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("code", "body")) + } + if body.Code != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.code", *body.Code, "^[a-z]+$")) + } + return +} + +// validateMultipartPartRequestBody checks MultipartPart and reports errors +// using the path supplied by its caller +func validateMultipartPartRequestBody(body *MultipartPartRequestBody, path string) (err error) { + if body.Code == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("code", path)) + } + if body.Code != nil { + err = goa.MergeErrors(err, goa.ValidatePattern(path+".code", *body.Code, "^[a-z]+$")) + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden b/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden index b6a9ca3eaa..0e266e10ea 100644 --- a/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden +++ b/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden @@ -62,18 +62,26 @@ func ValidateMethodBRequestBody(body *MethodBRequestBody) (err error) { err = goa.MergeErrors(err, goa.ValidatePattern("body.b", *body.B, "patternb")) } if body.C != nil { - if err2 := ValidateAPayloadRequestBody(body.C); err2 != nil { + if err2 := validateAPayloadRequestBody(body.C, "body.c"); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateAPayloadRequestBody runs the validations defined on -// APayloadRequestBody +// ValidateAPayloadRequestBody runs the validations defined on APayload func ValidateAPayloadRequestBody(body *APayloadRequestBody) (err error) { if body.A != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", *body.A, "patterna")) } return } + +// validateAPayloadRequestBody checks APayload and reports errors using the +// path supplied by its caller +func validateAPayloadRequestBody(body *APayloadRequestBody, path string) (err error) { + if body.A != nil { + err = goa.MergeErrors(err, goa.ValidatePattern(path+".a", *body.A, "patterna")) + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden b/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden index cdb65103b2..05882b271c 100644 --- a/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden +++ b/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden @@ -24,8 +24,6 @@ func NewMethodStreamingBody(body *MethodStreamingBody) *servicepayloadvalidateda func ValidateMethodStreamingBody(body *MethodStreamingBody) (err error) { if body.Name != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.name", string(*body.Name), "^[a-zA-Z]+$")) - } - if body.Name != nil { if utf8.RuneCountInString(string(*body.Name)) < 10 { err = goa.MergeErrors(err, goa.InvalidLengthError("body.name", string(*body.Name), utf8.RuneCountInString(string(*body.Name)), 10, true)) } diff --git a/http/codegen/testdata/golden/server_types_server-required-primitive-arrays.go.golden b/http/codegen/testdata/golden/server_types_server-required-primitive-arrays.go.golden new file mode 100644 index 0000000000..7911355343 --- /dev/null +++ b/http/codegen/testdata/golden/server_types_server-required-primitive-arrays.go.golden @@ -0,0 +1,75 @@ +// StoreRequestBody is the type of the "RequiredArrays" service "Store" +// endpoint HTTP request body. +type StoreRequestBody struct { + Names []*string `form:"names,omitempty" json:"names,omitempty" xml:"names,omitempty"` + Aliases []*string `form:"aliases,omitempty" json:"aliases,omitempty" xml:"aliases,omitempty"` +} + +// StoreResponseBody is the type of the "RequiredArrays" service "Store" +// endpoint HTTP response body. +type StoreResponseBody struct { + Names []string `form:"names" json:"names" xml:"names"` + Aliases []string `form:"aliases" json:"aliases" xml:"aliases"` +} + +// NewStoreResponseBody builds the HTTP response body from the result of the +// "Store" endpoint of the "RequiredArrays" service. +func NewStoreResponseBody(res *requiredarrays.StoreResult) *StoreResponseBody { + body := &StoreResponseBody{} + if res.Names != nil { + body.Names = make([]string, len(res.Names)) + for i, val := range res.Names { + body.Names[i] = val + } + } else { + body.Names = []string{} + } + if res.Aliases != nil { + body.Aliases = make([]string, len(res.Aliases)) + for i, val := range res.Aliases { + body.Aliases[i] = string(val) + } + } else { + body.Aliases = []string{} + } + return body +} + +// NewStorePayload builds a RequiredArrays service Store endpoint payload. +func NewStorePayload(body *StoreRequestBody) *requiredarrays.StorePayload { + v := &requiredarrays.StorePayload{} + v.Names = make([]string, len(body.Names)) + for i, val := range body.Names { + v.Names[i] = *val + } + v.Aliases = make([]requiredarrays.RequiredArrayAlias, len(body.Aliases)) + for i, val := range body.Aliases { + v.Aliases[i] = requiredarrays.RequiredArrayAlias(*val) + } + + return v +} + +// ValidateStoreRequestBody runs the validations defined on StoreRequestBody +func ValidateStoreRequestBody(body *StoreRequestBody) (err error) { + if body.Names == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("names", "body")) + } + if body.Aliases == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("aliases", "body")) + } + for _, e := range body.Names { + if e == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("body.names", "[*]")) + } + } + for _, e := range body.Aliases { + if e == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("body.aliases", "[*]")) + } + if e != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.aliases[*]", *e, "^[a-z]+$")) + } + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden b/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden index 411849e600..169ff45382 100644 --- a/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden @@ -65,7 +65,7 @@ func ValidateBidirectionalStreamStreamingBody(body *BidirectionalStreamStreaming } // ValidateStreamingRequestStreamingBody runs the validations defined on -// StreamingRequestStreamingBody +// StreamingRequest func ValidateStreamingRequestStreamingBody(body *StreamingRequestStreamingBody) (err error) { if body.Required == nil { err = goa.MergeErrors(err, goa.MissingFieldError("required", "body")) @@ -75,3 +75,15 @@ func ValidateStreamingRequestStreamingBody(body *StreamingRequestStreamingBody) } return } + +// validateStreamingRequestStreamingBody checks StreamingRequest and reports +// errors using the path supplied by its caller +func validateStreamingRequestStreamingBody(body *StreamingRequestStreamingBody, path string) (err error) { + if body.Required == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("required", path)) + } + if body.BaseRequired == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("baseRequired", path)) + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden index dd7aa4ebe0..a6b85deffc 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-collection.go.golden @@ -24,7 +24,7 @@ type RtResponseBody struct { func NewMethodResultWithResultCollectionResponseBody(res *serviceresultwithresultcollection.MethodResultWithResultCollectionResult) *MethodResultWithResultCollectionResponseBody { body := &MethodResultWithResultCollectionResponseBody{} if res.A != nil { - body.A = marshalServiceresultwithresultcollectionResulttypeToResulttypeResponseBody(res.A) + body.A = marshalServiceresultwithresultcollectionResulttypeToResulttypeResponseBodyOptional(res.A) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden index c0cf2ae38e..f4737636bb 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-nested-user-type-fields.go.golden @@ -24,10 +24,10 @@ type WrapperResponseBody struct { func NewMethodResultUserTypeNestedResponseBody(res *serviceresultusertypenestedviews.ResulttypenestedView) *MethodResultUserTypeNestedResponseBody { body := &MethodResultUserTypeNestedResponseBody{} if res.A != nil { - body.A = marshalServiceresultusertypenestedviewsUserTypeViewToUserTypeResponseBody(res.A) + body.A = marshalServiceresultusertypenestedviewsUserTypeViewToUserTypeResponseBodyOptional(res.A) } if res.Nested != nil { - body.Nested = marshalServiceresultusertypenestedviewsWrapperViewToWrapperResponseBody(res.Nested) + body.Nested = marshalServiceresultusertypenestedviewsWrapperViewToWrapperResponseBodyOptional(res.Nested) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden index fd9a4529a2..d656efd8bf 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-sibling-user-type-fields.go.golden @@ -19,10 +19,10 @@ type UserTypeResponseBody struct { func NewMethodResultUserTypeSiblingResponseBody(res *serviceresultusertypesiblingviews.ResulttypesiblingView) *MethodResultUserTypeSiblingResponseBody { body := &MethodResultUserTypeSiblingResponseBody{} if res.A != nil { - body.A = marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBody(res.A) + body.A = marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBodyOptional(res.A) } if res.B != nil { - body.B = marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBody(res.B) + body.B = marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBodyOptional(res.B) } return body } diff --git a/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden index 5f33466e4e..60c4cac176 100644 --- a/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden +++ b/http/codegen/testdata/golden/server_types_server-with-result-view.go.golden @@ -19,7 +19,7 @@ func NewMethodResultWithResultViewResponseBodyFull(res *serviceresultwithresultv Name: res.Name, } if res.Rt != nil { - body.Rt = marshalServiceresultwithresultviewviewsRtViewToRtResponseBody(res.Rt) + body.Rt = marshalServiceresultwithresultviewviewsRtViewToRtResponseBodyOptional(res.Rt) } return body } diff --git a/http/codegen/testdata/golden/sse-all-fields.golden b/http/codegen/testdata/golden/sse-all-fields.golden index 9c3b36535f..7e3daf3282 100644 --- a/http/codegen/testdata/golden/sse-all-fields.golden +++ b/http/codegen/testdata/golden/sse-all-fields.golden @@ -8,6 +8,8 @@ type SSEAllFieldsMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of @@ -21,6 +23,16 @@ func (s *SSEAllFieldsMethodServerStream) Send(v *sseallfieldsservice.SSEAllField // "sseallfieldsservice.SSEAllFieldsMethodResult" to the "SSEAllFieldsMethod" // endpoint SSE connection with context. func (s *SSEAllFieldsMethodServerStream) SendWithContext(ctx context.Context, v *sseallfieldsservice.SSEAllFieldsMethodResult) error { + res := v + + var data string + body := NewSSEAllFieldsMethodResponseBody(res) + + byts, err := json.Marshal(body.Data) + if err != nil { + return err + } + data = string(byts) s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -33,75 +45,36 @@ func (s *SSEAllFieldsMethodServerStream) SendWithContext(ctx context.Context, v header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) - res := v if id := res.ID; id != "" { - fmt.Fprintf(s.w, "id: %s\n", id) + if _, err := fmt.Fprintf(s.w, "id: %s\n", id); err != nil { + return err + } } if event := res.Event; event != "" { - fmt.Fprintf(s.w, "event: %s\n", event) - } - if retry := res.Retry; retry > 0 { - fmt.Fprintf(s.w, "retry: %d\n", retry) - } - - var data string - var payload any - body := NewSSEAllFieldsMethodResponseBody(res) - payload = body.Data - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" + if _, err := fmt.Fprintf(s.w, "event: %s\n", event); err != nil { + return err } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { + } + if retry := res.Retry; retry != nil && *retry > 0 { + if _, err := fmt.Fprintf(s.w, "retry: %d\n", *retry); err != nil { return err } - data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err + } - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEAllFieldsMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-bool.golden b/http/codegen/testdata/golden/sse-bool.golden index 4d88c2f936..a16fa5f3b5 100644 --- a/http/codegen/testdata/golden/sse-bool.golden +++ b/http/codegen/testdata/golden/sse-bool.golden @@ -7,6 +7,8 @@ type SSEBoolMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of "bool" to the "SSEBoolMethod" endpoint SSE @@ -18,6 +20,16 @@ func (s *SSEBoolMethodServerStream) Send(v bool) error { // SendWithContext SendWithContext streams instances of "bool" to the // "SSEBoolMethod" endpoint SSE connection with context. func (s *SSEBoolMethodServerStream) SendWithContext(ctx context.Context, v bool) error { + res := v + + var data string + body := res + + if body { + data = "true" + } else { + data = "false" + } s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -30,65 +42,21 @@ func (s *SSEBoolMethodServerStream) SendWithContext(ctx context.Context, v bool) header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) - res := v - var data string - var payload any - body := NewSSEBoolMethodResponseBody(res) - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err } - fmt.Fprintf(s.w, "data: %s\n\n", data) - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEBoolMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-client-all-fields.golden b/http/codegen/testdata/golden/sse-client-all-fields.golden index 9ceaf9cb32..7f4c9b54b4 100644 --- a/http/codegen/testdata/golden/sse-client-all-fields.golden +++ b/http/codegen/testdata/golden/sse-client-all-fields.golden @@ -198,28 +198,34 @@ func (s *SSEAllFieldsMethodStreamImpl) processEvent(eventData []byte) (event *ss continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } if bytes.HasPrefix(line, []byte("id:")) { - event.ID = s.trimHeader(len("id:"), line) + event.ID = s.trimHeader(line[len("id:"):]) continue } if bytes.HasPrefix(line, []byte("event:")) { - event.Event = s.trimHeader(len("event:"), line) + event.Event = s.trimHeader(line[len("event:"):]) continue } if bytes.HasPrefix(line, []byte("retry:")) { - // Note: retry value parsing depends on the field type; client currently expects integer-like types. - // We deliberately leave conversion to a future enhancement that includes the field type reference. - // For now this branch is kept for completeness; services using RetryField should be handled server-side. + retryContent := s.trimHeader(line[len("retry:"):]) + + var val int64 + val, err = strconv.ParseInt(retryContent, 10, 0) + if err != nil { + return + } + value := int(val) + event.Retry = &value continue } } if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") - // Use user-provided decoder for complex types + // The configured decoder handles structured event data. respBody := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), @@ -232,12 +238,8 @@ func (s *SSEAllFieldsMethodStreamImpl) processEvent(eventData []byte) (event *ss return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEAllFieldsMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEAllFieldsMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-bool.golden b/http/codegen/testdata/golden/sse-client-bool.golden index c2e5a00a96..08e8f45a01 100644 --- a/http/codegen/testdata/golden/sse-client-bool.golden +++ b/http/codegen/testdata/golden/sse-client-bool.golden @@ -197,32 +197,25 @@ func (s *SSEBoolMethodStreamImpl) processEvent(eventData []byte) (event bool, er continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } } if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") - // Use user-provided decoder for complex types - respBody := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), - } - err = s.decoder(respBody).Decode(&event) + var val bool + val, err = strconv.ParseBool(dataContent) if err != nil { return } + event = val } return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEBoolMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEBoolMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-data-field.golden b/http/codegen/testdata/golden/sse-client-data-field.golden index 830c91bc51..b100a04bd0 100644 --- a/http/codegen/testdata/golden/sse-client-data-field.golden +++ b/http/codegen/testdata/golden/sse-client-data-field.golden @@ -198,24 +198,21 @@ func (s *SSEDataFieldMethodStreamImpl) processEvent(eventData []byte) (event *ss continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } } if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") - event.Data = dataContent + value := dataContent + event.Data = &value } return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEDataFieldMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEDataFieldMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-data-id-field.golden b/http/codegen/testdata/golden/sse-client-data-id-field.golden index cbb7acf1f3..d41fb8eef9 100644 --- a/http/codegen/testdata/golden/sse-client-data-id-field.golden +++ b/http/codegen/testdata/golden/sse-client-data-id-field.golden @@ -198,28 +198,25 @@ func (s *SSEDataIDFieldMethodStreamImpl) processEvent(eventData []byte) (event * continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } if bytes.HasPrefix(line, []byte("id:")) { - event.ID = s.trimHeader(len("id:"), line) + event.ID = s.trimHeader(line[len("id:"):]) continue } } if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") - event.Data = dataContent + value := dataContent + event.Data = &value } return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEDataIDFieldMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEDataIDFieldMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-int.golden b/http/codegen/testdata/golden/sse-client-int.golden index e4786f790c..59e45bd850 100644 --- a/http/codegen/testdata/golden/sse-client-int.golden +++ b/http/codegen/testdata/golden/sse-client-int.golden @@ -197,7 +197,7 @@ func (s *SSEIntMethodStreamImpl) processEvent(eventData []byte) (event int, err continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } } @@ -214,12 +214,8 @@ func (s *SSEIntMethodStreamImpl) processEvent(eventData []byte) (event int, err return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEIntMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEIntMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-object.golden b/http/codegen/testdata/golden/sse-client-object.golden index a664f71f48..fbe69db8e8 100644 --- a/http/codegen/testdata/golden/sse-client-object.golden +++ b/http/codegen/testdata/golden/sse-client-object.golden @@ -198,13 +198,13 @@ func (s *SSEObjectMethodStreamImpl) processEvent(eventData []byte) (event *sseob continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } } if len(dataLines) > 0 { dataContent := strings.Join(dataLines, "\n") - // Decode JSON into the struct pointer directly + // Decode the event data into the result value returned by Recv. respBody := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), @@ -217,12 +217,8 @@ func (s *SSEObjectMethodStreamImpl) processEvent(eventData []byte) (event *sseob return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEObjectMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEObjectMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-request-id.golden b/http/codegen/testdata/golden/sse-client-request-id.golden index cb31a59bde..4c5d735a33 100644 --- a/http/codegen/testdata/golden/sse-client-request-id.golden +++ b/http/codegen/testdata/golden/sse-client-request-id.golden @@ -197,7 +197,7 @@ func (s *SSERequestIDMethodStreamImpl) processEvent(eventData []byte) (event str continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } } @@ -209,12 +209,8 @@ func (s *SSERequestIDMethodStreamImpl) processEvent(eventData []byte) (event str return } -// trimHeader removes the header prefix and optional leading space -func (s *SSERequestIDMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSERequestIDMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-client-string.golden b/http/codegen/testdata/golden/sse-client-string.golden index b418aa697b..7873b7e449 100644 --- a/http/codegen/testdata/golden/sse-client-string.golden +++ b/http/codegen/testdata/golden/sse-client-string.golden @@ -197,7 +197,7 @@ func (s *SSEStringMethodStreamImpl) processEvent(eventData []byte) (event string continue } if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + dataLines = append(dataLines, s.trimHeader(line[len("data:"):])) continue } } @@ -209,12 +209,8 @@ func (s *SSEStringMethodStreamImpl) processEvent(eventData []byte) (event string return } -// trimHeader removes the header prefix and optional leading space -func (s *SSEStringMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] +// trimHeader removes the optional space after an SSE field name. +func (s *SSEStringMethodStreamImpl) trimHeader(data []byte) string { if len(data) > 0 && data[0] == ' ' { data = data[1:] } diff --git a/http/codegen/testdata/golden/sse-data-field.golden b/http/codegen/testdata/golden/sse-data-field.golden index f61cb95147..0a035bd7b1 100644 --- a/http/codegen/testdata/golden/sse-data-field.golden +++ b/http/codegen/testdata/golden/sse-data-field.golden @@ -8,6 +8,8 @@ type SSEDataFieldMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of @@ -21,6 +23,17 @@ func (s *SSEDataFieldMethodServerStream) Send(v *ssedatafieldservice.SSEDataFiel // "ssedatafieldservice.SSEDataFieldMethodResult" to the "SSEDataFieldMethod" // endpoint SSE connection with context. func (s *SSEDataFieldMethodServerStream) SendWithContext(ctx context.Context, v *ssedatafieldservice.SSEDataFieldMethodResult) error { + res := v + + var data string + hasData := true + body := NewSSEDataFieldMethodResponseBody(res) + + if body.Data != nil { + data = string(*body.Data) + } else { + hasData = false + } s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -33,65 +46,26 @@ func (s *SSEDataFieldMethodServerStream) SendWithContext(ctx context.Context, v header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) - res := v - var data string - var payload any - body := NewSSEDataFieldMethodResponseBody(res) - payload = body.Data - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { + if hasData { + if _, err := fmt.Fprintf(s.w, "data: %s\n", data); err != nil { return err } - data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + if _, err := fmt.Fprintln(s.w); err != nil { + return err + } - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEDataFieldMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-data-id-field.golden b/http/codegen/testdata/golden/sse-data-id-field.golden index 3e7004aef2..558ca5ebdc 100644 --- a/http/codegen/testdata/golden/sse-data-id-field.golden +++ b/http/codegen/testdata/golden/sse-data-id-field.golden @@ -8,6 +8,8 @@ type SSEDataIDFieldMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of @@ -21,6 +23,17 @@ func (s *SSEDataIDFieldMethodServerStream) Send(v *ssedataidfieldservice.SSEData // "ssedataidfieldservice.SSEDataIDFieldMethodResult" to the // "SSEDataIDFieldMethod" endpoint SSE connection with context. func (s *SSEDataIDFieldMethodServerStream) SendWithContext(ctx context.Context, v *ssedataidfieldservice.SSEDataIDFieldMethodResult) error { + res := v + + var data string + hasData := true + body := NewSSEDataIDFieldMethodResponseBody(res) + + if body.Data != nil { + data = string(*body.Data) + } else { + hasData = false + } s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -33,69 +46,31 @@ func (s *SSEDataIDFieldMethodServerStream) SendWithContext(ctx context.Context, header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) - res := v if id := res.ID; id != "" { - fmt.Fprintf(s.w, "id: %s\n", id) - } - - var data string - var payload any - body := NewSSEDataIDFieldMethodResponseBody(res) - payload = body.Data - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" + if _, err := fmt.Fprintf(s.w, "id: %s\n", id); err != nil { + return err } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { + } + if hasData { + if _, err := fmt.Fprintf(s.w, "data: %s\n", data); err != nil { return err } - data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + if _, err := fmt.Fprintln(s.w); err != nil { + return err + } - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEDataIDFieldMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-int.golden b/http/codegen/testdata/golden/sse-int.golden index 952688803c..d608256209 100644 --- a/http/codegen/testdata/golden/sse-int.golden +++ b/http/codegen/testdata/golden/sse-int.golden @@ -7,6 +7,8 @@ type SSEIntMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of "int" to the "SSEIntMethod" endpoint SSE @@ -18,6 +20,12 @@ func (s *SSEIntMethodServerStream) Send(v int) error { // SendWithContext SendWithContext streams instances of "int" to the // "SSEIntMethod" endpoint SSE connection with context. func (s *SSEIntMethodServerStream) SendWithContext(ctx context.Context, v int) error { + res := v + + var data string + body := res + + data = fmt.Sprintf("%d", body) s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -30,65 +38,21 @@ func (s *SSEIntMethodServerStream) SendWithContext(ctx context.Context, v int) e header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) - res := v - var data string - var payload any - body := NewSSEIntMethodResponseBody(res) - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err } - fmt.Fprintf(s.w, "data: %s\n\n", data) - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEIntMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-object.golden b/http/codegen/testdata/golden/sse-object.golden index 14e24ddc88..bdd95f0f8b 100644 --- a/http/codegen/testdata/golden/sse-object.golden +++ b/http/codegen/testdata/golden/sse-object.golden @@ -8,6 +8,8 @@ type SSEObjectMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of "sseobjectservice.SSEObjectMethodResult" to @@ -20,6 +22,16 @@ func (s *SSEObjectMethodServerStream) Send(v *sseobjectservice.SSEObjectMethodRe // "sseobjectservice.SSEObjectMethodResult" to the "SSEObjectMethod" endpoint // SSE connection with context. func (s *SSEObjectMethodServerStream) SendWithContext(ctx context.Context, v *sseobjectservice.SSEObjectMethodResult) error { + res := v + + var data string + body := NewSSEObjectMethodResponseBody(res) + + byts, err := json.Marshal(body) + if err != nil { + return err + } + data = string(byts) s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -32,65 +44,21 @@ func (s *SSEObjectMethodServerStream) SendWithContext(ctx context.Context, v *ss header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) - res := v - var data string - var payload any - body := NewSSEObjectMethodResponseBody(res) - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err } - fmt.Fprintf(s.w, "data: %s\n\n", data) - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEObjectMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-request-id.golden b/http/codegen/testdata/golden/sse-request-id.golden index ce1e310295..9806fab2f0 100644 --- a/http/codegen/testdata/golden/sse-request-id.golden +++ b/http/codegen/testdata/golden/sse-request-id.golden @@ -8,6 +8,8 @@ type SSERequestIDMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of "string" to the "SSERequestIDMethod" endpoint @@ -19,6 +21,12 @@ func (s *SSERequestIDMethodServerStream) Send(v string) error { // SendWithContext SendWithContext streams instances of "string" to the // "SSERequestIDMethod" endpoint SSE connection with context. func (s *SSERequestIDMethodServerStream) SendWithContext(ctx context.Context, v string) error { + res := v + + var data string + body := res + + data = string(body) s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -31,65 +39,21 @@ func (s *SSERequestIDMethodServerStream) SendWithContext(ctx context.Context, v header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) - res := v - var data string - var payload any - body := NewSSERequestIDMethodResponseBody(res) - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err } - fmt.Fprintf(s.w, "data: %s\n\n", data) - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSERequestIDMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-string.golden b/http/codegen/testdata/golden/sse-string.golden index 4f5e7b9ed7..ee7822e07d 100644 --- a/http/codegen/testdata/golden/sse-string.golden +++ b/http/codegen/testdata/golden/sse-string.golden @@ -8,6 +8,8 @@ type SSEStringMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } // Send Send streams instances of "string" to the "SSEStringMethod" endpoint @@ -19,6 +21,12 @@ func (s *SSEStringMethodServerStream) Send(v string) error { // SendWithContext SendWithContext streams instances of "string" to the // "SSEStringMethod" endpoint SSE connection with context. func (s *SSEStringMethodServerStream) SendWithContext(ctx context.Context, v string) error { + res := v + + var data string + body := res + + data = string(body) s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -31,65 +39,21 @@ func (s *SSEStringMethodServerStream) SendWithContext(ctx context.Context, v str header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) - res := v - var data string - var payload any - body := NewSSEStringMethodResponseBody(res) - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err - } - data = string(byts) + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", data); err != nil { + return err } - fmt.Fprintf(s.w, "data: %s\n\n", data) - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEStringMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/transform_helper_bidirectional-client.go.golden b/http/codegen/testdata/golden/transform_helper_bidirectional-client.go.golden new file mode 100644 index 0000000000..a2e4081eeb --- /dev/null +++ b/http/codegen/testdata/golden/transform_helper_bidirectional-client.go.golden @@ -0,0 +1,41 @@ +// marshalServicebodyuserinnerdefaultInnerTypeToInnerTypeRequestBodyOptional +// builds a value of type *InnerTypeRequestBody from a value of type +// *servicebodyuserinnerdefault.InnerType. +func marshalServicebodyuserinnerdefaultInnerTypeToInnerTypeRequestBodyOptional(v *servicebodyuserinnerdefault.InnerType) *InnerTypeRequestBody { + if v == nil { + return nil + } + res := &InnerTypeRequestBody{ + A: v.A, + B: v.B, + } + { + var zero string + if res.B == zero { + res.B = "defaultb" + } + } + + return res +} + +// marshalInnerTypeRequestBodyToServicebodyuserinnerdefaultInnerTypeOptional +// builds a value of type *servicebodyuserinnerdefault.InnerType from a value +// of type *InnerTypeRequestBody. +func marshalInnerTypeRequestBodyToServicebodyuserinnerdefaultInnerTypeOptional(v *InnerTypeRequestBody) *servicebodyuserinnerdefault.InnerType { + if v == nil { + return nil + } + res := &servicebodyuserinnerdefault.InnerType{ + A: v.A, + B: v.B, + } + { + var zero string + if res.B == zero { + res.B = "defaultb" + } + } + + return res +} diff --git a/http/codegen/testdata/golden/transform_helper_shared-declarations.go.golden b/http/codegen/testdata/golden/transform_helper_shared-declarations.go.golden new file mode 100644 index 0000000000..6bf068fc26 --- /dev/null +++ b/http/codegen/testdata/golden/transform_helper_shared-declarations.go.golden @@ -0,0 +1,23 @@ +// unmarshalSharedChildRequestBodyToSharedhelpersSharedChild builds a value of +// type *sharedhelpers.SharedChild from a value of type *SharedChildRequestBody. +func unmarshalSharedChildRequestBodyToSharedhelpersSharedChild(v *SharedChildRequestBody) *sharedhelpers.SharedChild { + res := &sharedhelpers.SharedChild{ + Value: *v.Value, + } + + return res +} + +// unmarshalSharedChildRequestBodyToSharedhelpersSharedChildOptional builds a +// value of type *sharedhelpers.SharedChild from a value of type +// *SharedChildRequestBody. +func unmarshalSharedChildRequestBodyToSharedhelpersSharedChildOptional(v *SharedChildRequestBody) *sharedhelpers.SharedChild { + if v == nil { + return nil + } + res := &sharedhelpers.SharedChild{ + Value: *v.Value, + } + + return res +} diff --git a/http/codegen/testdata/golden/transform_helper_sibling-declarations.go.golden b/http/codegen/testdata/golden/transform_helper_sibling-declarations.go.golden new file mode 100644 index 0000000000..732ce9fe8c --- /dev/null +++ b/http/codegen/testdata/golden/transform_helper_sibling-declarations.go.golden @@ -0,0 +1,13 @@ +// marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBodyOptional +// builds a value of type *UserTypeResponseBody from a value of type +// *serviceresultusertypesiblingviews.UserTypeView. +func marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBodyOptional(v *serviceresultusertypesiblingviews.UserTypeView) *UserTypeResponseBody { + if v == nil { + return nil + } + res := &UserTypeResponseBody{ + U: v.U, + } + + return res +} diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden index a3ceeae6d6..654be71931 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden index f35627f687..379e652a51 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type BidirectionalComplexServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -135,9 +140,33 @@ func (s *BidirectionalComplexServerStream) RecvWithContext(ctx context.Context) // Close closes the "BidirectionalComplex" endpoint websocket connection. func (s *BidirectionalComplexServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalComplexServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive-client.golden index faadb67dc6..34aa73153b 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden index ba5d1bfda4..6ba33149e9 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type BidirectionalPrimitiveServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -129,9 +134,33 @@ func (s *BidirectionalPrimitiveServerStream) RecvWithContext(ctx context.Context // Close closes the "BidirectionalPrimitive" endpoint websocket connection. func (s *BidirectionalPrimitiveServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalPrimitiveServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden index f619e4ac03..268a18adc0 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden @@ -8,9 +8,9 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" + testserviceviews "generated.local/gen/test_service/views" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,9 +31,6 @@ type ConnConfigurer struct { type BidirectionalWithViewsClientStream struct { // conn is the underlying websocket connection. conn *websocket.Conn - // view is the view to render testservice.Request result type before sending to - // the websocket connection. - view string } // NewConnConfigurer initializes the websocket connection configurer function @@ -49,7 +46,7 @@ func NewConnConfigurer(fn goahttp.ConnConfigureFunc) *ConnConfigurer { func (s *BidirectionalWithViewsClientStream) Recv() (*testservice.Response, error) { var ( rv *testservice.Response - body BidirectionalWithViewsResponseBody + body BidirectionalWithViewsResponseBodyMinimal err error ) err = s.conn.ReadJSON(&body) @@ -60,7 +57,7 @@ func (s *BidirectionalWithViewsClientStream) Recv() (*testservice.Response, erro return rv, err } res := NewBidirectionalWithViewsResponseOK(&body) - vres := &testserviceviews.Response{Projected: res, View: s.view} + vres := &testserviceviews.Response{Projected: res, View: "minimal"} if err := testserviceviews.ValidateResponse(vres); err != nil { return rv, goahttp.ErrValidationError("TestService", "BidirectionalWithViews", err) } @@ -95,9 +92,3 @@ func (s *BidirectionalWithViewsClientStream) Close() error { } return s.conn.Close() } - -// SetView sets the view to render the testservice.Request type before sending -// to the "BidirectionalWithViews" endpoint websocket connection. -func (s *BidirectionalWithViewsClientStream) SetView(view string) { - s.view = view -} diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden index 53df220cd9..41a86bab81 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type BidirectionalWithViewsServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -44,9 +49,6 @@ type BidirectionalWithViewsServerStream struct { r *http.Request // conn is the underlying websocket connection. conn *websocket.Conn - // view is the view to render testservice.Response result type before sending - // to the websocket connection. - view string } // NewConnConfigurer initializes the websocket connection configurer function @@ -65,10 +67,8 @@ func (s *BidirectionalWithViewsServerStream) Send(v *testservice.Response) error // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { - respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) if err != nil { s.upgradeErr = err return @@ -81,14 +81,8 @@ func (s *BidirectionalWithViewsServerStream) Send(v *testservice.Response) error if s.upgradeErr != nil { return s.upgradeErr } - res := testservice.NewViewedResponse(v, s.view) - var body any - switch s.view { - case "default", "": - body = NewBidirectionalWithViewsResponseBody(res.Projected) - case "minimal": - body = NewBidirectionalWithViewsResponseBodyMinimal(res.Projected) - } + res := testservice.NewViewedResponse(v, "minimal") + body := NewBidirectionalWithViewsResponseBodyMinimal(res.Projected) return s.conn.WriteJSON(body) } @@ -146,9 +140,33 @@ func (s *BidirectionalWithViewsServerStream) RecvWithContext(ctx context.Context // Close closes the "BidirectionalWithViews" endpoint websocket connection. func (s *BidirectionalWithViewsServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalWithViewsServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -159,9 +177,3 @@ func (s *BidirectionalWithViewsServerStream) Close() error { } return s.conn.Close() } - -// SetView sets the view to render the testservice.Response type before sending -// to the "BidirectionalWithViews" endpoint websocket connection. -func (s *BidirectionalWithViewsServerStream) SetView(view string) { - s.view = view -} diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-array.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-array.golden index 2ce9547f11..d324bed471 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-array.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-array.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-object.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-object.golden index 094974849d..d01e872d1c 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-object.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-object.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-primitive.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-primitive.golden index 8f75c0c388..9a07b8b207 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-primitive.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-primitive.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-user-type.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-user-type.golden index ac7c52aea4..03c405d894 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-user-type.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-user-type.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-with-validation.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-with-validation.golden index 220ad3505f..9b4d32ab20 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-with-validation.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-with-validation.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-conn-configurer-client.golden b/http/codegen/testdata/golden/websocket/websocket-conn-configurer-client.golden index f4c0398268..7671bc270d 100644 --- a/http/codegen/testdata/golden/websocket/websocket-conn-configurer-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-conn-configurer-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden b/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden index 529ce34bcc..b966c516ff 100644 --- a/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden +++ b/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type ConfigurableStreamServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *ConfigurableStreamServerStream) SendWithContext(ctx context.Context, v // Close closes the "ConfigurableStream" endpoint websocket connection. func (s *ConfigurableStreamServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *ConfigurableStreamServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints-client.golden b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints-client.golden index 2dd48cf59f..16aded6d89 100644 --- a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden index f03105fd78..77067640f8 100644 --- a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden +++ b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StreamingEndpointServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *StreamingEndpointServerStream) SendWithContext(ctx context.Context, v s // Close closes the "StreamingEndpoint" endpoint websocket connection. func (s *StreamingEndpointServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamingEndpointServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden b/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden index cd1c4bf4b1..22fe380582 100644 --- a/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden +++ b/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type NoPayloadStreamServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *NoPayloadStreamServerStream) SendWithContext(ctx context.Context, v str // Close closes the "NoPayloadStream" endpoint websocket connection. func (s *NoPayloadStreamServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *NoPayloadStreamServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden b/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden index 764e00d6e6..083b5f4f88 100644 --- a/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden +++ b/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type NoResultStreamServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -97,9 +102,33 @@ func (s *NoResultStreamServerStream) RecvWithContext(ctx context.Context) (strin // Close closes the "NoResultStream" endpoint websocket connection. func (s *NoResultStreamServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *NoResultStreamServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden index 06109588a3..1f247e64d2 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StreamArrayServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *StreamArrayServerStream) SendWithContext(ctx context.Context, v []strin // Close closes the "StreamArray" endpoint websocket connection. func (s *StreamArrayServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamArrayServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden index e718e89ff1..d53ff69fab 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StreamObjectServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -89,9 +94,33 @@ func (s *StreamObjectServerStream) SendWithContext(ctx context.Context, v *tests // Close closes the "StreamObject" endpoint websocket connection. func (s *StreamObjectServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamObjectServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden index 2d098070c4..06f2727173 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StreamPrimitiveServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *StreamPrimitiveServerStream) SendWithContext(ctx context.Context, v str // Close closes the "StreamPrimitive" endpoint websocket connection. func (s *StreamPrimitiveServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamPrimitiveServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden index 70b1cf03c7..d03ea3a7f6 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StreamUserServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -89,9 +94,33 @@ func (s *StreamUserServerStream) SendWithContext(ctx context.Context, v *testser // Close closes the "StreamUser" endpoint websocket connection. func (s *StreamUserServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamUserServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden index af646086a5..e34e31fc65 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StreamUserWithViewsServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -47,6 +52,9 @@ type StreamUserWithViewsServerStream struct { // view is the view to render testservice.User result type before sending to // the websocket connection. view string + // sentView is the result view named during the WebSocket upgrade. Later sends + // must use the same view. + sentView string } // NewConnConfigurer initializes the websocket connection configurer function @@ -60,13 +68,26 @@ func NewConnConfigurer(fn goahttp.ConnConfigureFunc) *ConnConfigurer { // Send streams instances of "testservice.User" to the "StreamUserWithViews" // endpoint websocket connection. func (s *StreamUserWithViewsServerStream) Send(v *testservice.User) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + switch view { + case "default": + case "tiny": + default: + return goa.InvalidEnumValueError("view", view, []any{"default", "tiny"}) + } var err error // Upgrade the HTTP connection to a websocket connection only once. Connection // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) + respHdr.Add("goa-view", view) var conn *websocket.Conn conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) if err != nil { @@ -81,15 +102,19 @@ func (s *StreamUserWithViewsServerStream) Send(v *testservice.User) error { if s.upgradeErr != nil { return s.upgradeErr } - res := testservice.NewViewedUser(v, s.view) - var body any - switch s.view { + if s.sentView == "" { + s.sentView = view + } + switch view { case "default", "": - body = NewStreamUserWithViewsResponseBody(res.Projected) + res := testservice.NewViewedUser(v, "default") + return s.conn.WriteJSON(NewStreamUserWithViewsResponseBody(res.Projected)) case "tiny": - body = NewStreamUserWithViewsResponseBodyTiny(res.Projected) + res := testservice.NewViewedUser(v, "tiny") + return s.conn.WriteJSON(NewStreamUserWithViewsResponseBodyTiny(res.Projected)) + default: + return goa.InvalidEnumValueError("view", view, []any{"default", "tiny"}) } - return s.conn.WriteJSON(body) } // SendWithContext streams instances of "testservice.User" to the @@ -100,9 +125,45 @@ func (s *StreamUserWithViewsServerStream) SendWithContext(ctx context.Context, v // Close closes the "StreamUserWithViews" endpoint websocket connection. func (s *StreamUserWithViewsServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamUserWithViewsServerStream) close() error { var err error - if s.conn == nil { - return nil + view := s.view + if view == "" { + view = "default" + } + switch view { + case "default": + case "tiny": + default: + return goa.InvalidEnumValueError("view", view, []any{"default", "tiny"}) + } + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + respHdr := make(http.Header) + respHdr.Add("goa-view", view) + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-struct-types-client.golden b/http/codegen/testdata/golden/websocket/websocket-struct-types-client.golden index 4758960f20..d605273824 100644 --- a/http/codegen/testdata/golden/websocket/websocket-struct-types-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-struct-types-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-struct-types.golden b/http/codegen/testdata/golden/websocket/websocket-struct-types.golden index ff88ca3357..11b40e43c0 100644 --- a/http/codegen/testdata/golden/websocket/websocket-struct-types.golden +++ b/http/codegen/testdata/golden/websocket/websocket-struct-types.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StructStreamServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -131,9 +136,33 @@ func (s *StructStreamServerStream) RecvWithContext(ctx context.Context) (*testse // Close closes the "StructStream" endpoint websocket connection. func (s *StructStreamServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StructStreamServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/openapi_dsls.go b/http/codegen/testdata/openapi_dsls.go index e58211fdfe..85a8714ade 100644 --- a/http/codegen/testdata/openapi_dsls.go +++ b/http/codegen/testdata/openapi_dsls.go @@ -31,6 +31,28 @@ var SimpleDSL = func() { }) } +// BytesExampleDSL defines a response whose OpenAPI example must remain a +// string in both JSON and YAML documents. +var BytesExampleDSL = func() { + var _ = API("bytes", func() { + Server("bytes", func() { + Host("localhost", func() { + URI("https://goa.design") + }) + }) + }) + Service("bytes", func() { + Method("download", func() { + Result(Bytes, func() { + Example([]byte("hello")) + }) + HTTP(func() { + GET("/download") + }) + }) + }) +} + var MultipleServicesDSL = func() { var PayloadT = Type("Payload", func() { Attribute("string", String, func() { @@ -146,6 +168,49 @@ var ExplicitViewDSL = func() { }) } +// ReleasedResponseCollectionNamesDSL exercises the public OpenAPI component +// names for response collections whose elements use fixed views. +var ReleasedResponseCollectionNamesDSL = func() { + var StoredBottle = ResultType("application/vnd.stored-bottle", func() { + TypeName("StoredBottle") + Attributes(func() { + Attribute("name", String, func() { + Example("Blue's Cuvee") + }) + Attribute("vintage", UInt32, func() { + Example(2003) + }) + Required("name", "vintage") + }) + View("default", func() { + Attribute("name") + Attribute("vintage") + }) + View("tiny", func() { + Attribute("name") + }) + }) + + Service("storage", func() { + Method("list_default", func() { + Result(CollectionOf(StoredBottle), func() { + View("default") + }) + HTTP(func() { + GET("/default") + }) + }) + Method("list_tiny", func() { + Result(CollectionOf(StoredBottle), func() { + View("tiny") + }) + HTTP(func() { + GET("/tiny") + }) + }) + }) +} + var InvalidDSL = func() { var _ = API("test", func() { Server("test", func() { @@ -264,20 +329,26 @@ var IntValidationDSL = func() { var ArrayValidationDSL = func() { var Bar = Type("bar", func() { + Example(Val{"string": "item"}) Attribute("string", String, func() { MinLength(0) MaxLength(42) - Example("") + Example("item") }) }) var FooBar = Type("foobar", func() { - Attribute("foo", ArrayOf(String), func() { + Example(Val{"foo": []any{"item"}, "bar": []any{Val{"string": "item"}}}) + Attribute("foo", ArrayOf(String, func() { + Example("item") + }), func() { MinLength(0) MaxLength(42) + Example([]any{"item"}) }) Attribute("bar", ArrayOf(Bar), func() { MinLength(0) MaxLength(42) + Example([]any{Val{"string": "item"}}) }) }) var _ = API("test", func() { @@ -289,7 +360,9 @@ var ArrayValidationDSL = func() { }) Service("testService", func() { Method("testEndpoint", func() { - Payload(ArrayOf(FooBar)) + Payload(ArrayOf(FooBar), func() { + Example([]any{Val{"foo": []any{"item"}, "bar": []any{Val{"string": "item"}}}}) + }) Result(String, func() { MinLength(0) MaxLength(42) @@ -504,6 +577,7 @@ var ServerHostWithVariablesDSL = func() { var WithSpacesDSL = func() { var Bar = Type("bar", func() { + Example(Val{"string": "item"}) Attribute("string", String, func() { Example("") }) @@ -513,12 +587,16 @@ var WithSpacesDSL = func() { Attribute("foo", String, func() { Example("") }) - Attribute("bar", ArrayOf(Bar)) + Attribute("bar", ArrayOf(Bar), func() { + Example([]any{Val{"string": "item"}}) + }) }) Service("test service", func() { Method("test endpoint", func() { Payload(Bar) - Result(FooBar) + Result(FooBar, func() { + Example(Val{"foo": "", "bar": []any{Val{"string": "item"}}}) + }) HTTP(func() { POST("/") Response(StatusOK) @@ -580,25 +658,33 @@ var WithAnyDSL = func() { Service("testService", func() { Method("testEndpoint", func() { Payload(func() { + Example(Val{"any": "", "any_array": []any{""}, "any_map": Val{"key": ""}}) Attribute("any", Any, func() { Example("") }) Attribute("any_array", ArrayOf(Any, func() { Example("") - })) + }), func() { + Example([]any{""}) + }) Attribute("any_map", MapOf(String, Any), func() { + Example(Val{"key": ""}) Key(func() { Example("") }) Elem(func() { Example("") }) }) }) Result(func() { + Example(Val{"any": "", "any_array": []any{""}, "any_map": Val{"key": ""}}) Attribute("any", Any, func() { Example("") }) Attribute("any_array", ArrayOf(Any, func() { Example("") - })) + }), func() { + Example([]any{""}) + }) Attribute("any_map", MapOf(String, Any), func() { + Example(Val{"key": ""}) Key(func() { Example("") }) Elem(func() { Example("") }) }) @@ -614,7 +700,9 @@ var PathWithWildcardDSL = func() { Service("test service", func() { Method("test endpoint", func() { Payload(func() { - Attribute("int_map", Int) + Attribute("int_map", Int, func() { + Example(1) + }) }) HTTP(func() { POST("/{*int_map}") @@ -627,8 +715,12 @@ var PathWithMultipleWildcardDSL = func() { Service("test service", func() { Method("test endpoint", func() { Payload(func() { - Attribute("foo", Int) - Attribute("bar", Int) + Attribute("foo", Int, func() { + Example(1) + }) + Attribute("bar", Int, func() { + Example(2) + }) }) HTTP(func() { POST("/{bar}") @@ -644,8 +736,12 @@ var PathWithMultipleExplicitWildcardDSL = func() { Service("test service", func() { Method("test endpoint", func() { Payload(func() { - Attribute("foo", Int) - Attribute("bar", Int) + Attribute("foo", Int, func() { + Example(1) + }) + Attribute("bar", Int, func() { + Example(2) + }) }) HTTP(func() { POST("/{bar}") @@ -663,8 +759,12 @@ var HeadersDSL = func() { Service("test service", func() { Method("test endpoint", func() { Payload(func() { - Attribute("foo", Int) - Attribute("bar", Int) + Attribute("foo", Int, func() { + Example(1) + }) + Attribute("bar", Int, func() { + Example(2) + }) }) HTTP(func() { POST("/") @@ -687,7 +787,9 @@ var WithTagsDSL = func() { }) Method("test endpoint", func() { Payload(func() { - Attribute("int_map", Int) + Attribute("int_map", Int, func() { + Example(1) + }) }) HTTP(func() { Meta("openapi:tag:SomeTag") @@ -732,7 +834,9 @@ var WithTagsSwaggerDSL = func() { }) Method("test endpoint", func() { Payload(func() { - Attribute("int_map", Int) + Attribute("int_map", Int, func() { + Example(1) + }) }) HTTP(func() { Meta("swagger:tag:SomeTag") @@ -872,7 +976,9 @@ var NotGenerateServerDSL = func() { }) Service("testService", func() { Method("testEndpoint", func() { - Result(String) + Result(String, func() { + Example("ok") + }) HTTP(func() { GET("/") }) @@ -891,7 +997,9 @@ var NotGenerateHostDSL = func() { }) Service("testService", func() { Method("testEndpoint", func() { - Result(String) + Result(String, func() { + Example("ok") + }) HTTP(func() { GET("/") }) @@ -1167,7 +1275,9 @@ var OpenAPIInvalidVersionDSL = func() { var TypeExtensionDSL = func() { var Notification = Type("Notification", func() { Meta("openapi:extension:x-test-include", "true") - Attribute("id", String) + Attribute("id", String, func() { + Example("notice") + }) }) Service("testService", func() { Method("testEndpoint", func() { @@ -1184,10 +1294,14 @@ var AliasTypeDSL = func() { var Stage = Type("Stage", String, func() { Description("Setup stage.") Enum("who", "when", "where", "what") + Example("who") }) var Setup = Type("Setup", func() { + Example(Val{"current": "who", "completed": []any{"when"}}) Attribute("current", Stage) - Attribute("completed", ArrayOf(Stage)) + Attribute("completed", ArrayOf(Stage), func() { + Example([]any{"when"}) + }) }) Service("testService", func() { Method("testEndpoint", func() { diff --git a/http/codegen/testdata/payload_dsls.go b/http/codegen/testdata/payload_dsls.go index ddd9359bf1..8d0945739b 100644 --- a/http/codegen/testdata/payload_dsls.go +++ b/http/codegen/testdata/payload_dsls.go @@ -3075,6 +3075,28 @@ var PayloadMultipartUserTypeDSL = func() { }) } +var PayloadMultipartValidationDSL = func() { + var Part = Type("MultipartPart", func() { + Attribute("code", String, func() { + Pattern("^[a-z]+$") + }) + Required("code") + }) + Service("ServiceMultipartValidation", func() { + Method("MethodMultipartValidation", func() { + Payload(func() { + Attribute("name", String) + Attribute("part", Part) + Required("name", "part") + }) + HTTP(func() { + POST("/") + MultipartRequest() + }) + }) + }) +} + var PayloadMultipartArrayTypeDSL = func() { var PayloadType = Type("PayloadType", func() { Attribute("a", String, func() { @@ -3142,7 +3164,8 @@ var PayloadMultipartWithParamsAndHeadersDSL = func() { Pattern("patternb") }) Attribute("c", MapOf(Int, ArrayOf(String))) - Required("a", "c") + Attribute("d", String) + Required("a", "c", "d") }) Service("ServiceMultipartWithParamsAndHeaders", func() { Method("MethodMultipartWithParamsAndHeaders", func() { diff --git a/http/codegen/testdata/required_array_dsls.go b/http/codegen/testdata/required_array_dsls.go new file mode 100644 index 0000000000..b2b32f080d --- /dev/null +++ b/http/codegen/testdata/required_array_dsls.go @@ -0,0 +1,32 @@ +// This file defines the HTTP service used to verify required primitive array +// elements in generated request and response types. +package testdata + +import ( + . "goa.design/goa/v3/dsl" +) + +// RequiredPrimitiveArrayDSL defines primitive and named primitive arrays whose +// JSON elements must not be null. +var RequiredPrimitiveArrayDSL = func() { + alias := Type("RequiredArrayAlias", String, func() { + Pattern("^[a-z]+$") + }) + Service("RequiredArrays", func() { + Method("Store", func() { + Payload(func() { + Attribute("names", ArrayOfRequired(String)) + Attribute("aliases", ArrayOfRequired(alias)) + Required("names", "aliases") + }) + Result(func() { + Attribute("names", ArrayOfRequired(String)) + Attribute("aliases", ArrayOfRequired(alias)) + Required("names", "aliases") + }) + HTTP(func() { + POST("/required-arrays") + }) + }) + }) +} diff --git a/http/codegen/testdata/result_decode_functions.go b/http/codegen/testdata/result_decode_functions.go deleted file mode 100644 index 6e08aae07d..0000000000 --- a/http/codegen/testdata/result_decode_functions.go +++ /dev/null @@ -1,936 +0,0 @@ -package testdata - -var EmptyServerResponseDecodeCode = `// DecodeMethodEmptyServerResponseResponse returns a decoder for responses -// returned by the ServiceEmptyServerResponse MethodEmptyServerResponse -// endpoint. restoreBody controls whether the response body should be restored -// after having been read. -func DecodeMethodEmptyServerResponseResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - res := NewMethodEmptyServerResponseResultOK() - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceEmptyServerResponse", "MethodEmptyServerResponse", resp.StatusCode, string(body)) - } - } -} -` - -var ResultBodyMultipleViewsDecodeCode = `// DecodeMethodBodyMultipleViewResponse returns a decoder for responses -// returned by the ServiceBodyMultipleView MethodBodyMultipleView endpoint. -// restoreBody controls whether the response body should be restored after -// having been read. -func DecodeMethodBodyMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - body MethodBodyMultipleViewResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodBodyMultipleViewResulttypemultipleviewsOK(&body, c) - view := resp.Header.Get("goa-view") - vres := &servicebodymultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - if err = servicebodymultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) - } - res := servicebodymultipleview.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceBodyMultipleView", "MethodBodyMultipleView", resp.StatusCode, string(body)) - } - } -} -` - -var EmptyBodyResultMultipleViewsDecodeCode = `// DecodeMethodEmptyBodyResultMultipleViewResponse returns a decoder for -// responses returned by the ServiceEmptyBodyResultMultipleView -// MethodEmptyBodyResultMultipleView endpoint. restoreBody controls whether the -// response body should be restored after having been read. -func DecodeMethodEmptyBodyResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodEmptyBodyResultMultipleViewResulttypemultipleviewsOK(c) - view := resp.Header.Get("goa-view") - vres := &serviceemptybodyresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - res := serviceemptybodyresultmultipleview.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", resp.StatusCode, string(body)) - } - } -} -` - -var ExplicitBodyPrimitiveResultDecodeCode = `// DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse returns a -// decoder for responses returned by the -// ServiceExplicitBodyPrimitiveResultMultipleView -// MethodExplicitBodyPrimitiveResultMultipleView endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - body string - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) - } - if utf8.RuneCountInString(body) < 5 { - err = goa.MergeErrors(err, goa.InvalidLengthError("body", body, utf8.RuneCountInString(body), 5, true)) - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodExplicitBodyPrimitiveResultMultipleViewResulttypemultipleviewsOK(body, c) - view := resp.Header.Get("goa-view") - vres := &serviceexplicitbodyprimitiveresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - if err = serviceexplicitbodyprimitiveresultmultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) - } - res := serviceexplicitbodyprimitiveresultmultipleview.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", resp.StatusCode, string(body)) - } - } -} -` - -var ExplicitBodyUserResultMultipleViewsDecodeCode = `// DecodeMethodExplicitBodyUserResultMultipleViewResponse returns a decoder for -// responses returned by the ServiceExplicitBodyUserResultMultipleView -// MethodExplicitBodyUserResultMultipleView endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodExplicitBodyUserResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - body MethodExplicitBodyUserResultMultipleViewResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodExplicitBodyUserResultMultipleViewResulttypemultipleviewsOK(&body, c) - view := resp.Header.Get("goa-view") - vres := &serviceexplicitbodyuserresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - if err = serviceexplicitbodyuserresultmultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) - } - res := serviceexplicitbodyuserresultmultipleview.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", resp.StatusCode, string(body)) - } - } -} -` - -var ExplicitBodyResultCollectionDecodeCode = `// DecodeMethodExplicitBodyResultCollectionResponse returns a decoder for -// responses returned by the ServiceExplicitBodyResultCollection -// MethodExplicitBodyResultCollection endpoint. restoreBody controls whether -// the response body should be restored after having been read. -func DecodeMethodExplicitBodyResultCollectionResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - body ResulttypeCollection - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) - } - err = ValidateResulttypeCollection(body) - if err != nil { - return nil, goahttp.ErrValidationError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) - } - res := NewMethodExplicitBodyResultCollectionResultOK(body) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", resp.StatusCode, string(body)) - } - } -} -` - -var ResultMultipleViewsTagDecodeCode = `// DecodeMethodTagMultipleViewsResponse returns a decoder for responses -// returned by the ServiceTagMultipleViews MethodTagMultipleViews endpoint. -// restoreBody controls whether the response body should be restored after -// having been read. -func DecodeMethodTagMultipleViewsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusAccepted: - var ( - body MethodTagMultipleViewsAcceptedResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("C") - if cRaw != "" { - c = &cRaw - } - p := NewMethodTagMultipleViewsResulttypemultipleviewsAccepted(&body, c) - tmp := "value" - p.B = &tmp - view := resp.Header.Get("goa-view") - vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} - if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - res := servicetagmultipleviews.NewResulttypemultipleviews(vres) - return res, nil - case http.StatusOK: - var ( - body MethodTagMultipleViewsOKResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - p := NewMethodTagMultipleViewsResulttypemultipleviewsOK(&body) - view := resp.Header.Get("goa-view") - vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} - if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - res := servicetagmultipleviews.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceTagMultipleViews", "MethodTagMultipleViews", resp.StatusCode, string(body)) - } - } -} -` - -var EmptyServerResponseWithTagsDecodeCode = `// DecodeMethodEmptyServerResponseWithTagsResponse returns a decoder for -// responses returned by the ServiceEmptyServerResponseWithTags -// MethodEmptyServerResponseWithTags endpoint. restoreBody controls whether the -// response body should be restored after having been read. -func DecodeMethodEmptyServerResponseWithTagsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusNotModified: - res := NewMethodEmptyServerResponseWithTagsResultNotModified() - res.H = "true" - return res, nil - case http.StatusNoContent: - res := NewMethodEmptyServerResponseWithTagsResultNoContent() - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderStringImplicitResponseDecodeCode = `// DecodeMethodHeaderStringImplicitResponse returns a decoder for responses -// returned by the ServiceHeaderStringImplicit MethodHeaderStringImplicit -// endpoint. restoreBody controls whether the response body should be restored -// after having been read. -func DecodeMethodHeaderStringImplicitResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - h string - err error - ) - hRaw := resp.Header.Get("H") - if hRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("h", "header")) - } - h = hRaw - if err != nil { - return nil, goahttp.ErrValidationError("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", err) - } - return h, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderStringArrayResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceHeaderStringArrayResponse MethodA endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - array []string - ) - array = resp.Header["Array"] - - res := NewMethodAResultOK(array) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringArrayResponse", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderStringArrayValidateResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceHeaderStringArrayValidateResponse MethodA endpoint. restoreBody -// controls whether the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - array []string - err error - ) - array = resp.Header["Array"] - - if len(array) < 5 { - err = goa.MergeErrors(err, goa.InvalidLengthError("array", array, len(array), 5, true)) - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceHeaderStringArrayValidateResponse", "MethodA", err) - } - res := NewMethodAResultOK(array) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringArrayValidateResponse", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderArrayResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceHeaderArrayResponse MethodA endpoint. restoreBody controls whether -// the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - array []uint - err error - ) - { - arrayRaw := resp.Header["Array"] - - if arrayRaw != nil { - array = make([]uint, len(arrayRaw)) - for i, rv := range arrayRaw { - v, err2 := strconv.ParseUint(rv, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("array", arrayRaw, "array of unsigned integers")) - } - array[i] = uint(v) - } - } - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceHeaderArrayResponse", "MethodA", err) - } - res := NewMethodAResultOK(array) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderArrayResponse", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderArrayValidateResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceHeaderArrayValidateResponse MethodA endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - array []int - err error - ) - { - arrayRaw := resp.Header["Array"] - - if arrayRaw != nil { - array = make([]int, len(arrayRaw)) - for i, rv := range arrayRaw { - v, err2 := strconv.ParseInt(rv, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("array", arrayRaw, "array of integers")) - } - array[i] = int(v) - } - } - } - for _, e := range array { - if e < 5 { - err = goa.MergeErrors(err, goa.InvalidRangeError("array[*]", e, 5, true)) - } - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceHeaderArrayValidateResponse", "MethodA", err) - } - res := NewMethodAResultOK(array) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderArrayValidateResponse", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var WithHeadersBlockResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceWithHeadersBlock MethodA endpoint. restoreBody controls whether the -// response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - required int - optional *float32 - optionalButRequired uint - err error - ) - { - requiredRaw := resp.Header.Get("X-Request-Id") - if requiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlock", "MethodA", goa.MissingFieldError("required", "header")) - } - v, err2 := strconv.ParseInt(requiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("required", requiredRaw, "integer")) - } - required = int(v) - } - { - optionalRaw := resp.Header.Get("Authorization") - if optionalRaw != "" { - v, err2 := strconv.ParseFloat(optionalRaw, 32) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional", optionalRaw, "float")) - } - pv := float32(v) - optional = &pv - } - } - { - optionalButRequiredRaw := resp.Header.Get("Location") - if optionalButRequiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlock", "MethodA", goa.MissingFieldError("optional_but_required", "header")) - } - v, err2 := strconv.ParseUint(optionalButRequiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional_but_required", optionalButRequiredRaw, "unsigned integer")) - } - optionalButRequired = uint(v) - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlock", "MethodA", err) - } - res := NewMethodAResultOK(required, optional, optionalButRequired) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceWithHeadersBlock", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var WithHeadersBlockViewedResultResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceWithHeadersBlockViewedResult MethodA endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - required int - optional *float32 - optionalButRequired uint - err error - ) - { - requiredRaw := resp.Header.Get("X-Request-Id") - if requiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.MissingFieldError("required", "header")) - } - v, err2 := strconv.ParseInt(requiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("required", requiredRaw, "integer")) - } - required = int(v) - } - { - optionalRaw := resp.Header.Get("Authorization") - if optionalRaw != "" { - v, err2 := strconv.ParseFloat(optionalRaw, 32) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional", optionalRaw, "float")) - } - pv := float32(v) - optional = &pv - } - } - { - optionalButRequiredRaw := resp.Header.Get("Location") - if optionalButRequiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.MissingFieldError("optional_but_required", "header")) - } - v, err2 := strconv.ParseUint(optionalButRequiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional_but_required", optionalButRequiredRaw, "unsigned integer")) - } - optionalButRequired = uint(v) - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", err) - } - p := NewMethodAAResultOK(required, optional, optionalButRequired) - view := resp.Header.Get("goa-view") - vres := &servicewithheadersblockviewedresultviews.AResult{Projected: p, View: view} - res := servicewithheadersblockviewedresult.NewAResult(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceWithHeadersBlockViewedResult", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var ValidateErrorResponseTypeDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ValidateErrorResponseType MethodA endpoint. restoreBody controls whether the -// response body should be restored after having been read. -// DecodeMethodAResponse may return the following errors: -// - "some_error" (type *validateerrorresponsetype.AError): http.StatusBadRequest -// - error: internal error -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - required int - err error - ) - { - requiredRaw := resp.Header.Get("X-Request-Id") - if requiredRaw == "" { - return nil, goahttp.ErrValidationError("ValidateErrorResponseType", "MethodA", goa.MissingFieldError("required", "header")) - } - v, err2 := strconv.ParseInt(requiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("required", requiredRaw, "integer")) - } - required = int(v) - } - if err != nil { - return nil, goahttp.ErrValidationError("ValidateErrorResponseType", "MethodA", err) - } - p := NewMethodAAResultOK(required) - view := "default" - vres := &validateerrorresponsetypeviews.AResult{Projected: p, View: view} - res := validateerrorresponsetype.NewAResult(vres) - return res, nil - case http.StatusBadRequest: - var ( - error_ string - numOccur *int - err error - ) - error_Raw := resp.Header.Get("X-Application-Error") - if error_Raw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("error", "header")) - } - error_ = error_Raw - { - numOccurRaw := resp.Header.Get("X-Occur") - if numOccurRaw != "" { - v, err2 := strconv.ParseInt(numOccurRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("num_occur", numOccurRaw, "integer")) - } - pv := int(v) - numOccur = &pv - } - } - if numOccur != nil { - if *numOccur < 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("num_occur", *numOccur, 1, true)) - } - } - if err != nil { - return nil, goahttp.ErrValidationError("ValidateErrorResponseType", "MethodA", err) - } - return nil, NewMethodASomeError(error_, numOccur) - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ValidateErrorResponseType", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var EmptyErrorResponseBodyDecodeCode = `// DecodeMethodEmptyErrorResponseBodyResponse returns a decoder for responses -// returned by the ServiceEmptyErrorResponseBody MethodEmptyErrorResponseBody -// endpoint. restoreBody controls whether the response body should be restored -// after having been read. -// DecodeMethodEmptyErrorResponseBodyResponse may return the following errors: -// - "internal_error" (type *goa.ServiceError): http.StatusInternalServerError -// - "not_found" (type serviceemptyerrorresponsebody.NotFound): http.StatusNotFound -// - error: internal error -func DecodeMethodEmptyErrorResponseBodyResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - return nil, nil - case http.StatusInternalServerError: - var ( - name string - id string - message string - temporary bool - timeout bool - fault bool - err error - ) - nameRaw := resp.Header.Get("Error-Name") - if nameRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("name", "header")) - } - name = nameRaw - idRaw := resp.Header.Get("Goa-Attribute-Id") - if idRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("id", "header")) - } - id = idRaw - messageRaw := resp.Header.Get("Goa-Attribute-Message") - if messageRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("message", "header")) - } - message = messageRaw - { - temporaryRaw := resp.Header.Get("Goa-Attribute-Temporary") - if temporaryRaw == "" { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", goa.MissingFieldError("temporary", "header")) - } - v, err2 := strconv.ParseBool(temporaryRaw) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("temporary", temporaryRaw, "boolean")) - } - temporary = v - } - { - timeoutRaw := resp.Header.Get("Goa-Attribute-Timeout") - if timeoutRaw == "" { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", goa.MissingFieldError("timeout", "header")) - } - v, err2 := strconv.ParseBool(timeoutRaw) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("timeout", timeoutRaw, "boolean")) - } - timeout = v - } - { - faultRaw := resp.Header.Get("Goa-Attribute-Fault") - if faultRaw == "" { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", goa.MissingFieldError("fault", "header")) - } - v, err2 := strconv.ParseBool(faultRaw) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("fault", faultRaw, "boolean")) - } - fault = v - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err) - } - return nil, NewMethodEmptyErrorResponseBodyInternalError(name, id, message, temporary, timeout, fault) - case http.StatusNotFound: - var ( - inHeader string - err error - ) - inHeaderRaw := resp.Header.Get("In-Header") - if inHeaderRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("in-header", "header")) - } - inHeader = inHeaderRaw - if err != nil { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err) - } - return nil, NewMethodEmptyErrorResponseBodyNotFound(inHeader) - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", resp.StatusCode, string(body)) - } - } -} -` diff --git a/http/codegen/testdata/shared_error_description_dsl.go b/http/codegen/testdata/shared_error_description_dsl.go new file mode 100644 index 0000000000..4e90f5c10b --- /dev/null +++ b/http/codegen/testdata/shared_error_description_dsl.go @@ -0,0 +1,63 @@ +// This file defines HTTP designs that reuse one named error type from several +// methods so OpenAPI tests can distinguish type text from response text. +package testdata + +import . "goa.design/goa/v3/dsl" + +var ( + // SharedErrorDescriptionDSL describes the shared type and declares the first + // method before the second method. + SharedErrorDescriptionDSL = sharedErrorDescriptionDSL(false, "Shared error value") + + // ReversedSharedErrorDescriptionDSL declares the same methods in reverse + // order to prove that method order does not change the shared schema. + ReversedSharedErrorDescriptionDSL = sharedErrorDescriptionDSL(true, "Shared error value") + + // UndescribedSharedErrorDSL leaves the shared type without a description so + // a method description cannot become the shared schema description. + UndescribedSharedErrorDSL = sharedErrorDescriptionDSL(false, "") +) + +// sharedErrorDescriptionDSL returns a design with two method errors that share +// one type but explain different failures to callers. +func sharedErrorDescriptionDSL(reverse bool, typeDescription string) func() { + return func() { + sharedError := Type("SharedError", func() { + if typeDescription != "" { + Description(typeDescription) + } + Attribute("message", String, "Error message", func() { + Example("shared failure") + }) + Required("message") + }) + + Service("errors", func() { + first := func() { + Method("first", func() { + Error("first_error", sharedError, "First failure") + HTTP(func() { + GET("/first") + Response("first_error", StatusBadRequest) + }) + }) + } + second := func() { + Method("second", func() { + Error("second_error", sharedError, "Second failure") + HTTP(func() { + GET("/second") + Response("second_error", StatusBadRequest) + }) + }) + } + if reverse { + second() + first() + return + } + first() + second() + }) + } +} diff --git a/http/codegen/testdata/sse_dsls.go b/http/codegen/testdata/sse_dsls.go index d7de8bbf2b..ff84f9bf21 100644 --- a/http/codegen/testdata/sse_dsls.go +++ b/http/codegen/testdata/sse_dsls.go @@ -7,7 +7,9 @@ import ( var SSEStringDSL = func() { Service("SSEStringService", func() { Method("SSEStringMethod", func() { - StreamingResult(String) + StreamingResult(String, func() { + Example("event") + }) HTTP(func() { GET("/string") ServerSentEvents() @@ -44,9 +46,15 @@ var SSEObjectDSL = func() { Service("SSEObjectService", func() { Method("SSEObjectMethod", func() { StreamingResult(func() { - Attribute("id", String) - Attribute("value", Int) - Attribute("flag", Boolean) + Attribute("id", String, func() { + Example("event") + }) + Attribute("value", Int, func() { + Example(1) + }) + Attribute("flag", Boolean, func() { + Example(true) + }) }) HTTP(func() { GET("/object") @@ -60,8 +68,12 @@ var SSEDataFieldDSL = func() { Service("SSEDataFieldService", func() { Method("SSEDataFieldMethod", func() { StreamingResult(func() { - Attribute("data", String) - Attribute("flag", Boolean) + Attribute("data", String, func() { + Example("event") + }) + Attribute("flag", Boolean, func() { + Example(true) + }) }) HTTP(func() { GET("/data-field") @@ -92,9 +104,13 @@ var SSERequestIDDSL = func() { Service("SSERequestIDService", func() { Method("SSERequestIDMethod", func() { Payload(func() { - Attribute("id", String) + Attribute("id", String, func() { + Example("request") + }) + }) + StreamingResult(String, func() { + Example("event") }) - StreamingResult(String) HTTP(func() { GET("/request-id") ServerSentEvents(func() { @@ -109,7 +125,9 @@ var SSEAllFieldsDSL = func() { Service("SSEAllFieldsService", func() { Method("SSEAllFieldsMethod", func() { Payload(func() { - Attribute("id", String) + Attribute("id", String, func() { + Example("request") + }) }) StreamingResult(func() { Attribute("id", String, func() { diff --git a/http/codegen/testdata/streaming_code.go b/http/codegen/testdata/streaming_code.go index 4b9dc28626..1b508f7efb 100644 --- a/http/codegen/testdata/streaming_code.go +++ b/http/codegen/testdata/streaming_code.go @@ -1,3 +1,5 @@ +// This file contains expected HTTP streaming sections used to verify that +// websocket client and server code names the exact catalog-owned wire types. package testdata var MixedEndpointsConnConfigurerStructCode = `// ConnConfigurer holds the websocket connection configurer functions for the @@ -120,6 +122,13 @@ func NewCreateHandler( } _, err = endpoint(ctx, v) if err != nil { + stream := v.Stream.(*CreateServerStream) + if stream.attempted { + if errhandler != nil { + errhandler(ctx, w, err) + } + return + } if err := encodeError(ctx, w, err); err != nil && errhandler != nil { errhandler(ctx, w, err) } @@ -158,8 +167,8 @@ func NewCreateHandler( // discardCreateServerStream implements the mixedresultsservice.CreateServerStream // interface and drops all events. It is used for mixed results endpoints in -// unary (non-SSE) mode so service implementations can use the stream parameter -// without nil checks. +// regular HTTP requests so service implementations can use the stream +// parameter without nil checks. type discardCreateServerStream struct{} // Send discards the event. @@ -214,9 +223,33 @@ func (s *StreamingResultMethodServerStream) SendWithContext(ctx context.Context, var StreamingResultServerStreamCloseCode = `// Close closes the "StreamingResultMethod" endpoint websocket connection. func (s *StreamingResultMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamingResultMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -232,13 +265,27 @@ func (s *StreamingResultMethodServerStream) Close() error { var StreamingResultWithViewsServerStreamSendCode = `// Send streams instances of "streamingresultwithviewsservice.Usertype" to the // "StreamingResultWithViewsMethod" endpoint websocket connection. func (s *StreamingResultWithViewsMethodServerStream) Send(v *streamingresultwithviewsservice.Usertype) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + switch view { + case "tiny": + case "extended": + case "default": + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) + } var err error // Upgrade the HTTP connection to a websocket connection only once. Connection // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) + respHdr.Add("goa-view", view) var conn *websocket.Conn conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) if err != nil { @@ -253,17 +300,22 @@ func (s *StreamingResultWithViewsMethodServerStream) Send(v *streamingresultwith if s.upgradeErr != nil { return s.upgradeErr } - res := streamingresultwithviewsservice.NewViewedUsertype(v, s.view) - var body any - switch s.view { + if s.sentView == "" { + s.sentView = view + } + switch view { case "tiny": - body = NewStreamingResultWithViewsMethodResponseBodyTiny(res.Projected) + res := streamingresultwithviewsservice.NewViewedUsertype(v, "tiny") + return s.conn.WriteJSON(NewStreamingResultWithViewsMethodResponseBodyTiny(res.Projected)) case "extended": - body = NewStreamingResultWithViewsMethodResponseBodyExtended(res.Projected) + res := streamingresultwithviewsservice.NewViewedUsertype(v, "extended") + return s.conn.WriteJSON(NewStreamingResultWithViewsMethodResponseBodyExtended(res.Projected)) case "default", "": - body = NewStreamingResultWithViewsMethodResponseBody(res.Projected) + res := streamingresultwithviewsservice.NewViewedUsertype(v, "default") + return s.conn.WriteJSON(NewStreamingResultWithViewsMethodResponseBody(res.Projected)) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) } - return s.conn.WriteJSON(body) } // SendWithContext streams instances of @@ -379,9 +431,46 @@ func (c *Client) StreamingResultMethod() goa.Endpoint { var StreamingResultWithViewsServerStreamCloseCode = `// Close closes the "StreamingResultWithViewsMethod" endpoint websocket // connection. func (s *StreamingResultWithViewsMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamingResultWithViewsMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + view := s.view + if view == "" { + view = "default" + } + switch view { + case "tiny": + case "extended": + case "default": + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) + } + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + respHdr := make(http.Header) + respHdr.Add("goa-view", view) + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -614,13 +703,27 @@ var StreamingResultCollectionWithViewsServerStreamSendCode = `// Send streams in // "streamingresultcollectionwithviewsservice.UsertypeCollection" to the // "StreamingResultCollectionWithViewsMethod" endpoint websocket connection. func (s *StreamingResultCollectionWithViewsMethodServerStream) Send(v streamingresultcollectionwithviewsservice.UsertypeCollection) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + switch view { + case "tiny": + case "extended": + case "default": + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) + } var err error // Upgrade the HTTP connection to a websocket connection only once. Connection // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) + respHdr.Add("goa-view", view) var conn *websocket.Conn conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) if err != nil { @@ -635,17 +738,22 @@ func (s *StreamingResultCollectionWithViewsMethodServerStream) Send(v streamingr if s.upgradeErr != nil { return s.upgradeErr } - res := streamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, s.view) - var body any - switch s.view { + if s.sentView == "" { + s.sentView = view + } + switch view { case "tiny": - body = NewUsertypeResponseTinyCollection(res.Projected) + res := streamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "tiny") + return s.conn.WriteJSON(NewUsertypeResponseTinyCollection(res.Projected)) case "extended": - body = NewUsertypeResponseExtendedCollection(res.Projected) + res := streamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "extended") + return s.conn.WriteJSON(NewUsertypeResponseExtendedCollection(res.Projected)) case "default", "": - body = NewUsertypeResponseCollection(res.Projected) + res := streamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "default") + return s.conn.WriteJSON(NewUsertypeResponseCollection(res.Projected)) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) } - return s.conn.WriteJSON(body) } // SendWithContext streams instances of @@ -672,7 +780,7 @@ var StreamingResultCollectionWithViewsClientStreamRecvCode = `// Recv reads inst func (s *StreamingResultCollectionWithViewsMethodClientStream) Recv() (streamingresultcollectionwithviewsservice.UsertypeCollection, error) { var ( rv streamingresultcollectionwithviewsservice.UsertypeCollection - body StreamingResultCollectionWithViewsMethodResponseBody + body UsertypeResponseCollection err error ) err = s.conn.ReadJSON(&body) @@ -1553,9 +1661,33 @@ func (s *StreamingPayloadNoResultMethodServerStream) RecvWithContext(ctx context var StreamingPayloadNoResultServerStreamCloseCode = `// Close closes the "StreamingPayloadNoResultMethod" endpoint websocket // connection. func (s *StreamingPayloadNoResultMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamingPayloadNoResultMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -1599,16 +1731,8 @@ var StreamingPayloadResultWithViewsServerStreamSendCode = `// SendAndClose strea // closes the connection. func (s *StreamingPayloadResultWithViewsMethodServerStream) SendAndClose(v *streamingpayloadresultwithviewsservice.Usertype) error { defer s.conn.Close() - res := streamingpayloadresultwithviewsservice.NewViewedUsertype(v, s.view) - var body any - switch s.view { - case "tiny": - body = NewStreamingPayloadResultWithViewsMethodResponseBodyTiny(res.Projected) - case "extended": - body = NewStreamingPayloadResultWithViewsMethodResponseBodyExtended(res.Projected) - case "default", "": - body = NewStreamingPayloadResultWithViewsMethodResponseBody(res.Projected) - } + res := streamingpayloadresultwithviewsservice.NewViewedUsertype(v, "tiny") + body := NewStreamingPayloadResultWithViewsMethodResponseBodyTiny(res.Projected) return s.conn.WriteJSON(body) } @@ -1693,7 +1817,7 @@ var StreamingPayloadResultWithViewsClientStreamRecvCode = `// CloseAndRecv stops func (s *StreamingPayloadResultWithViewsMethodClientStream) CloseAndRecv() (*streamingpayloadresultwithviewsservice.Usertype, error) { var ( rv *streamingpayloadresultwithviewsservice.Usertype - body StreamingPayloadResultWithViewsMethodResponseBody + body StreamingPayloadResultWithViewsMethodResponseBodyTiny err error ) defer s.conn.Close() @@ -1710,7 +1834,7 @@ func (s *StreamingPayloadResultWithViewsMethodClientStream) CloseAndRecv() (*str return rv, err } res := NewStreamingPayloadResultWithViewsMethodUsertypeOK(&body) - vres := &streamingpayloadresultwithviewsserviceviews.Usertype{Projected: res, View: s.view} + vres := &streamingpayloadresultwithviewsserviceviews.Usertype{Projected: res, View: "tiny"} if err := streamingpayloadresultwithviewsserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingPayloadResultWithViewsService", "StreamingPayloadResultWithViewsMethod", err) } @@ -1857,16 +1981,8 @@ var StreamingPayloadResultCollectionWithViewsServerStreamSendCode = `// SendAndC // connection and closes the connection. func (s *StreamingPayloadResultCollectionWithViewsMethodServerStream) SendAndClose(v streamingpayloadresultcollectionwithviewsservice.UsertypeCollection) error { defer s.conn.Close() - res := streamingpayloadresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, s.view) - var body any - switch s.view { - case "tiny": - body = NewUsertypeResponseTinyCollection(res.Projected) - case "extended": - body = NewUsertypeResponseExtendedCollection(res.Projected) - case "default", "": - body = NewUsertypeResponseCollection(res.Projected) - } + res := streamingpayloadresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "tiny") + body := NewUsertypeResponseTinyCollection(res.Projected) return s.conn.WriteJSON(body) } @@ -1955,7 +2071,7 @@ var StreamingPayloadResultCollectionWithViewsClientStreamRecvCode = `// CloseAnd func (s *StreamingPayloadResultCollectionWithViewsMethodClientStream) CloseAndRecv() (streamingpayloadresultcollectionwithviewsservice.UsertypeCollection, error) { var ( rv streamingpayloadresultcollectionwithviewsservice.UsertypeCollection - body StreamingPayloadResultCollectionWithViewsMethodResponseBody + body UsertypeResponseTinyCollection err error ) defer s.conn.Close() @@ -1972,7 +2088,7 @@ func (s *StreamingPayloadResultCollectionWithViewsMethodClientStream) CloseAndRe return rv, err } res := NewStreamingPayloadResultCollectionWithViewsMethodUsertypeCollectionOK(body) - vres := streamingpayloadresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: s.view} + vres := streamingpayloadresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: "tiny"} if err := streamingpayloadresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("StreamingPayloadResultCollectionWithViewsService", "StreamingPayloadResultCollectionWithViewsMethod", err) } @@ -2806,9 +2922,33 @@ func (s *BidirectionalStreamingMethodServerStream) RecvWithContext(ctx context.C var BidirectionalStreamingServerStreamCloseCode = `// Close closes the "BidirectionalStreamingMethod" endpoint websocket // connection. func (s *BidirectionalStreamingMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalStreamingMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -2968,9 +3108,33 @@ func NewBidirectionalStreamingNoPayloadMethodHandler( var BidirectionalStreamingNoPayloadServerStreamCloseCode = `// Close closes the "BidirectionalStreamingNoPayloadMethod" endpoint websocket // connection. func (s *BidirectionalStreamingNoPayloadMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalStreamingNoPayloadMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -3077,10 +3241,8 @@ func (s *BidirectionalStreamingResultWithViewsMethodServerStream) Send(v *bidire // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { - respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) if err != nil { s.upgradeErr = err return @@ -3093,16 +3255,8 @@ func (s *BidirectionalStreamingResultWithViewsMethodServerStream) Send(v *bidire if s.upgradeErr != nil { return s.upgradeErr } - res := bidirectionalstreamingresultwithviewsservice.NewViewedUsertype(v, s.view) - var body any - switch s.view { - case "tiny": - body = NewBidirectionalStreamingResultWithViewsMethodResponseBodyTiny(res.Projected) - case "extended": - body = NewBidirectionalStreamingResultWithViewsMethodResponseBodyExtended(res.Projected) - case "default", "": - body = NewBidirectionalStreamingResultWithViewsMethodResponseBody(res.Projected) - } + res := bidirectionalstreamingresultwithviewsservice.NewViewedUsertype(v, "tiny") + body := NewBidirectionalStreamingResultWithViewsMethodResponseBodyTiny(res.Projected) return s.conn.WriteJSON(body) } @@ -3161,9 +3315,33 @@ func (s *BidirectionalStreamingResultWithViewsMethodServerStream) RecvWithContex var BidirectionalStreamingResultWithViewsServerStreamCloseCode = `// Close closes the "BidirectionalStreamingResultWithViewsMethod" endpoint // websocket connection. func (s *BidirectionalStreamingResultWithViewsMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalStreamingResultWithViewsMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -3205,7 +3383,7 @@ var BidirectionalStreamingResultWithViewsClientStreamRecvCode = `// Recv reads i func (s *BidirectionalStreamingResultWithViewsMethodClientStream) Recv() (*bidirectionalstreamingresultwithviewsservice.Usertype, error) { var ( rv *bidirectionalstreamingresultwithviewsservice.Usertype - body BidirectionalStreamingResultWithViewsMethodResponseBody + body BidirectionalStreamingResultWithViewsMethodResponseBodyTiny err error ) err = s.conn.ReadJSON(&body) @@ -3216,7 +3394,7 @@ func (s *BidirectionalStreamingResultWithViewsMethodClientStream) Recv() (*bidir return rv, err } res := NewBidirectionalStreamingResultWithViewsMethodUsertypeOK(&body) - vres := &bidirectionalstreamingresultwithviewsserviceviews.Usertype{Projected: res, View: s.view} + vres := &bidirectionalstreamingresultwithviewsserviceviews.Usertype{Projected: res, View: "tiny"} if err := bidirectionalstreamingresultwithviewsserviceviews.ValidateUsertype(vres); err != nil { return rv, goahttp.ErrValidationError("BidirectionalStreamingResultWithViewsService", "BidirectionalStreamingResultWithViewsMethod", err) } @@ -3392,10 +3570,8 @@ func (s *BidirectionalStreamingResultCollectionWithViewsMethodServerStream) Send // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { - respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) if err != nil { s.upgradeErr = err return @@ -3408,16 +3584,8 @@ func (s *BidirectionalStreamingResultCollectionWithViewsMethodServerStream) Send if s.upgradeErr != nil { return s.upgradeErr } - res := bidirectionalstreamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, s.view) - var body any - switch s.view { - case "tiny": - body = NewUsertypeResponseTinyCollection(res.Projected) - case "extended": - body = NewUsertypeResponseExtendedCollection(res.Projected) - case "default", "": - body = NewUsertypeResponseCollection(res.Projected) - } + res := bidirectionalstreamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "tiny") + body := NewUsertypeResponseTinyCollection(res.Projected) return s.conn.WriteJSON(body) } @@ -3506,7 +3674,7 @@ var BidirectionalStreamingResultCollectionWithViewsClientStreamRecvCode = `// Re func (s *BidirectionalStreamingResultCollectionWithViewsMethodClientStream) Recv() (bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection, error) { var ( rv bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection - body BidirectionalStreamingResultCollectionWithViewsMethodResponseBody + body UsertypeResponseTinyCollection err error ) err = s.conn.ReadJSON(&body) @@ -3517,7 +3685,7 @@ func (s *BidirectionalStreamingResultCollectionWithViewsMethodClientStream) Recv return rv, err } res := NewBidirectionalStreamingResultCollectionWithViewsMethodUsertypeCollectionOK(body) - vres := bidirectionalstreamingresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: s.view} + vres := bidirectionalstreamingresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: "tiny"} if err := bidirectionalstreamingresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { return rv, goahttp.ErrValidationError("BidirectionalStreamingResultCollectionWithViewsService", "BidirectionalStreamingResultCollectionWithViewsMethod", err) } diff --git a/http/codegen/testdata/streaming_dsls.go b/http/codegen/testdata/streaming_dsls.go index 4bd2bb815e..74d525c3a9 100644 --- a/http/codegen/testdata/streaming_dsls.go +++ b/http/codegen/testdata/streaming_dsls.go @@ -34,6 +34,22 @@ var SkipRequestBodyEncodeDecodeDSL = func() { }) } +var SkipRequestBodyEncodeDecodeHeaderDSL = func() { + Service("SkipRequestBodyEncodeDecodeHeader", func() { + Method("Upload", func() { + Payload(func() { + Attribute("contentType", String) + }) + HTTP(func() { + POST("/") + Header("contentType:Content-Type") + SkipRequestBodyEncodeDecode() + Response(StatusNoContent) + }) + }) + }) +} + var StreamingMultipleServicesDSL = func() { Service("StreamingServiceA", func() { Method("Method", func() { @@ -57,10 +73,14 @@ var StreamingMultipleServicesDSL = func() { var StreamingResultDSL = func() { var Request = Type("Request", func() { - Attribute("x", String) + Attribute("x", String, func() { + Example("request") + }) }) var Result = Type("UserType", func() { - Attribute("a", String) + Attribute("a", String, func() { + Example("event") + }) }) Service("StreamingResultService", func() { Method("StreamingResultMethod", func() { @@ -76,15 +96,21 @@ var StreamingResultDSL = func() { var MixedResultsDSL = func() { var PayloadType = Type("Payload", func() { - Attribute("x", String) + Attribute("x", String, func() { + Example("request") + }) Required("x") }) var ResultType = Type("Result", func() { - Attribute("id", String) + Attribute("id", String, func() { + Example("result") + }) Required("id") }) var EventType = Type("Event", func() { - Attribute("message", String) + Attribute("message", String, func() { + Example("event") + }) Required("message") }) Service("MixedResultsService", func() { @@ -491,7 +517,9 @@ var StreamingPayloadResultWithViewsDSL = func() { Service("StreamingPayloadResultWithViewsService", func() { Method("StreamingPayloadResultWithViewsMethod", func() { StreamingPayload(Float32) - Result(ResultT) + Result(ResultT, func() { + View("tiny") + }) HTTP(func() { GET("/") Response(StatusOK) @@ -549,7 +577,9 @@ var StreamingPayloadResultCollectionWithViewsDSL = func() { Service("StreamingPayloadResultCollectionWithViewsService", func() { Method("StreamingPayloadResultCollectionWithViewsMethod", func() { StreamingPayload(Any) - Result(CollectionOf(ResultT)) + Result(CollectionOf(ResultT), func() { + View("tiny") + }) HTTP(func() { GET("/") Response(StatusOK) @@ -730,7 +760,9 @@ var BidirectionalStreamingResultWithViewsDSL = func() { Service("BidirectionalStreamingResultWithViewsService", func() { Method("BidirectionalStreamingResultWithViewsMethod", func() { StreamingPayload(Float32) - StreamingResult(ResultT) + StreamingResult(ResultT, func() { + View("tiny") + }) HTTP(func() { GET("/") Response(StatusOK) @@ -788,7 +820,9 @@ var BidirectionalStreamingResultCollectionWithViewsDSL = func() { Service("BidirectionalStreamingResultCollectionWithViewsService", func() { Method("BidirectionalStreamingResultCollectionWithViewsMethod", func() { StreamingPayload(Any) - StreamingResult(CollectionOf(ResultT)) + StreamingResult(CollectionOf(ResultT), func() { + View("tiny") + }) HTTP(func() { GET("/") Response(StatusOK) diff --git a/http/codegen/testing.go b/http/codegen/testing.go deleted file mode 100644 index 94e1b0023c..0000000000 --- a/http/codegen/testing.go +++ /dev/null @@ -1,15 +0,0 @@ -package codegen - -import ( - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/service" - "goa.design/goa/v3/expr" -) - -// CreateHTTPServices creates a new ServicesData instance for testing. The -// root is normalized first like the production Generate flow does before the -// generators read the design. -func CreateHTTPServices(root *expr.RootExpr) *ServicesData { - codegen.NormalizeRoot(root) - return NewServicesData(service.NewServicesData(root), root.API.HTTP) -} diff --git a/http/codegen/transform_helper_test.go b/http/codegen/transform_helper_test.go index 3744d2f81b..d585395384 100644 --- a/http/codegen/transform_helper_test.go +++ b/http/codegen/transform_helper_test.go @@ -1,59 +1,586 @@ +// This file verifies HTTP and JSON-RPC conversion functions use the exact +// declarations selected while their generated packages are planned. package codegen import ( + "bytes" + "fmt" "testing" - "goa.design/goa/v3/codegen/testutil" - "goa.design/goa/v3/expr" - "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - // "goa.design/goa/v3/http/codegen/testdata" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" ) -func TestTransformHelperServer(t *testing.T) { - cases := []struct { - Name string - DSL func() - Offset int - }{ - // {"body-user-inner-default-1", testdata.PayloadBodyUserInnerDefaultDSL1, 1}, - // {"body-user-recursive-default-1", testdata.PayloadBodyInlineRecursiveUserDSL1, 1}, - } - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - f := ServerEncodeDecodeFile("", root.API.HTTP.Services[0], services) - sections := f.SectionTemplates - require.Greater(t, len(sections), c.Offset) - code := codegen.SectionCode(t, sections[len(sections)-c.Offset]) - testutil.AssertGo(t, "testdata/golden/transform_helper_"+c.Name+".go.golden", code) +// TestTransformHelperOrderingSupportsMoreThan255Functions catches helper name +// ordering that narrows a plan position to one byte. +func TestTransformHelperOrderingSupportsMoreThan255Functions(t *testing.T) { + source, target := manyDistinctTransformChildren(257, false) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy) + catalog.collectTransform(source, target, "marshal", "many helpers", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + + require.NoError(t, catalog.Declare()) + require.NoError(t, generation.Freeze()) +} + +// TestTransformHandleSelectsTheCollectedPlan catches structurally identical +// conversions being exchanged when rendering happens in a different order. +func TestTransformHandleSelectsTheCollectedPlan(t *testing.T) { + source, target := manyDistinctTransformChildren(1, false) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy) + first := catalog.collectTransform(source, target, "marshal", "first", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + second := catalog.collectTransform(source, target, "marshal", "second", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + linkTestWireTypeCatalog(t, generation, catalog) + + _, _, err := renderTestTransform(catalog, second, "inventory") + require.NoError(t, err) + require.False(t, first.record.used) + require.True(t, second.record.used) + _, _, err = renderTestTransform(catalog, first, "inventory") + require.NoError(t, err) +} + +// TestTransformHandleRejectsAnotherCatalog catches a planned conversion being +// rendered into a package that did not claim its declarations. +func TestTransformHandleRejectsAnotherCatalog(t *testing.T) { + source, target := manyDistinctTransformChildren(1, false) + first, firstGeneration := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + first.collect(target, wireRequestBody, policy) + handle := first.collectTransform(source, target, "marshal", "first", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + linkTestWireTypeCatalog(t, firstGeneration, first) + + second, secondGeneration := testWireTypeCatalog(t) + linkTestWireTypeCatalog(t, secondGeneration, second) + _, _, err := renderTestTransform(second, handle, "inventory") + require.ErrorContains(t, err, "different generated package") +} + +// TestTransformDefinitionsMatchAcrossPlans proves one package declaration may +// be shared by equivalent helper definitions produced by separate plans. +func TestTransformDefinitionsMatchAcrossPlans(t *testing.T) { + catalog, first, second := plannedMatchingTransforms(t) + _, firstHelpers, err := renderTestTransform(catalog, first, "inventory") + require.NoError(t, err) + _, secondHelpers, err := renderTestTransform(catalog, second, "inventory") + require.NoError(t, err) + require.Same(t, firstHelpers[0].Declaration, secondHelpers[0].Declaration) +} + +// TestTransformDefinitionsRejectMismatchAcrossPlans catches AppendHelpers +// hiding two different functions assigned to one package declaration. +func TestTransformDefinitionsRejectMismatchAcrossPlans(t *testing.T) { + catalog, first, second := plannedMatchingTransforms(t) + _, _, err := renderTestTransform(catalog, first, "inventory") + require.NoError(t, err) + _, _, err = renderTestTransform(catalog, second, "different") + require.ErrorContains(t, err, "has different definitions") + require.False(t, second.record.used) +} + +// TestPlanLinkRejectsUnusedTransform catches planned conversions that no +// generated constructor or stream method renders. +func TestPlanLinkRejectsUnusedTransform(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("audit", func() { + dsl.Method("show", func() { + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.GET("/show") + }) + }) }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + transportService := root.API.HTTP.Service("audit") + planned := plans[0].wireTypes[transportService] + record := &wireTransformRecord{owner: "unused audit transform", prefix: "marshal"} + planned.client.transforms = append(planned.client.transforms, record) + planned.transforms.streamingResults[transportService.HTTPEndpoints[0]] = &plannedResponseTransforms{ + clientDecode: wireTransformHandle{catalog: planned.client, record: record}, } + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + + err = plans[0].Link() + require.ErrorContains(t, err, "unused audit transform") +} + +// TestPlanLinkReturnsForeignTransformError catches render failures escaping as +// panics instead of the error returned by Plan.Link. +func TestPlanLinkReturnsForeignTransformError(t *testing.T) { + root, generation, servicePlan, plan := plannedTransformErrorService(t) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + planned := plan.wireTypes[root.API.HTTP.Services[0]] + response := planned.transforms.responses[viewedConstructorKey{endpoint: endpoint, response: endpoint.Responses[0]}] + response.serverEncode = response.clientDecode + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + + var linkErr error + require.NotPanics(t, func() { + linkErr = plan.Link() + }) + require.ErrorContains(t, linkErr, "different generated package") +} + +// TestPlanLinkReturnsReusedTransformError catches a second production render +// of one handle escaping as a panic. +func TestPlanLinkReturnsReusedTransformError(t *testing.T) { + root, generation, servicePlan, plan := plannedTransformErrorService(t) + serviceExpr := root.API.HTTP.Services[0] + endpoint := serviceExpr.HTTPEndpoints[0] + planned := plan.wireTypes[serviceExpr] + request := planned.transforms.requests[clientBodyConstructorKey{endpoint: endpoint, role: wireRequestBody}] + response := planned.transforms.responses[viewedConstructorKey{endpoint: endpoint, response: endpoint.Responses[0]}] + response.serverEncode = request.serverDecode + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + + var linkErr error + require.NotPanics(t, func() { + linkErr = plan.Link() + }) + require.ErrorContains(t, linkErr, "already rendered") } -func TestTransformHelperCLI(t *testing.T) { - cases := []struct { - Name string - DSL func() - Offset int +// TestTransformHelperOrderingDoesNotDependOnTraversalOrder catches suffixes +// changing when the same child conversions are collected in reverse order. +func TestTransformHelperOrderingDoesNotDependOnTraversalOrder(t *testing.T) { + require.Equal(t, plannedTransformHelperNames(t, false), plannedTransformHelperNames(t, true)) +} + +// TestTransformHelperUsesRetainedServicePackagePreference catches helper names +// derived from the HTTP output directory or a copied view type's spelling. +func TestTransformHelperUsesRetainedServicePackagePreference(t *testing.T) { + for _, test := range []struct { + name string + preference codegen.ImportSpec + want string }{ - // {"cli-body-user-inner-default-1", testdata.PayloadBodyUserInnerDefaultDSLCLI1, 1}, - // {"cli-body-user-inner-default-2", testdata.PayloadBodyUserInnerDefaultDSLCLI2, 2}, - // {"cli-body-user-recursive-default-1", testdata.PayloadBodyInlineRecursiveUserDSLCLI1, 1}, - // {"cli-body-user-recursive-default-2", testdata.PayloadBodyInlineRecursiveUserDSLCLI2, 2}, - } - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - f := ClientEncodeDecodeFile("", root.API.HTTP.Services[0], services) - sections := f.SectionTemplates - require.Greater(t, len(sections), c.Offset) - code := codegen.SectionCode(t, sections[len(sections)-c.Offset]) - testutil.AssertGo(t, "testdata/golden/transform_helper_"+c.Name+".go.golden", code) + {"service", codegen.ImportSpec{Name: "inventory", Path: "generated.local/gen/inventory"}, "InventoryChild"}, + {"views", codegen.ImportSpec{Name: "inventoryviews", Path: "generated.local/gen/inventory/views"}, "InventoryviewsChild"}, + } { + t.Run(test.name, func(t *testing.T) { + source, target := manyDistinctTransformChildren(1, false) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy) + catalog.collectTransform(source, target, "marshal", test.name, wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: test.preference, + }) + linkTestWireTypeCatalog(t, generation, catalog) + + require.Len(t, catalog.transformHelpers, 1) + require.Contains(t, catalog.transformHelpers[0].declaration.Name(), test.want) + require.NotContains(t, catalog.transformHelpers[0].declaration.Name(), "TestChild") }) } } + +func TestClientTransformHelpersNameExactSourceAndTargetTypes(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadBodyUserInnerDefaultDSL) + plan := linkedHTTPPlanForRoot(t, root) + var ( + code bytes.Buffer + count int + ) + for _, file := range plan.ClientFiles() { + for _, section := range file.SectionTemplates { + if section.Name != "client-transform-helper" { + continue + } + count++ + require.NoError(t, section.Write(&code)) + } + } + require.Equal(t, 2, count) + testutil.AssertGo( + t, + "testdata/golden/transform_helper_bidirectional-client.go.golden", + codegen.FormatTestCode(t, "package client\n"+code.String()), + ) +} + +func TestViewedTransformHelpersNameViewsPackage(t *testing.T) { + root := expr.RunDSL(t, testdata.ExplicitBodyUserResultObjectDSL) + plan := linkedHTTPPlanForRoot(t, root) + service := plan.services.Get("ServiceExplicitBodyUserResultObject") + names := make([]string, 0, len(service.ClientTransformHelpers)) + for _, helper := range service.ClientTransformHelpers { + names = append(names, helper.Name) + } + require.Contains(t, names, "unmarshalUserTypeResponseBodyToServiceexplicitbodyuserresultobjectviewsUserTypeViewOptional") +} + +func TestTransformHelpersUseConciseServiceAndWireTypeNames(t *testing.T) { + root := expr.RunDSL(t, conciseTransformHelperDSL) + plan := linkedHTTPPlanForRoot(t, root) + service := plan.services.Get("Storage") + names := make([]string, 0, len(service.ClientTransformHelpers)) + for _, helper := range service.ClientTransformHelpers { + names = append(names, helper.Name) + } + require.Contains(t, names, "marshalStorageWineryToWineryRequestBody") + require.Contains(t, names, "marshalWineryRequestBodyToStorageWinery") +} + +func TestSiblingTransformHelpersShareOneDefinition(t *testing.T) { + root := expr.RunDSL(t, testdata.ResultTypeSiblingUserTypeFieldsDSL) + plan := linkedHTTPPlanForRoot(t, root) + service := plan.services.Get("ServiceResultUserTypeSibling") + require.Len(t, service.ServerTransformHelpers, 1) + name := "marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBodyOptional" + require.Equal(t, name, service.ServerTransformHelpers[0].Name) + result := service.Endpoint("MethodResultUserTypeSibling").Result + require.NotEmpty(t, result.Responses) + require.NotEmpty(t, result.Responses[0].ServerBody) + require.NotNil(t, result.Responses[0].ServerBody[0].Init) + constructor := result.Responses[0].ServerBody[0].Init.ServerCode + require.Contains(t, constructor, name+"(res.A)") + require.Contains(t, constructor, name+"(res.B)") + + var code bytes.Buffer + for _, file := range plan.ServerFiles() { + for _, section := range file.SectionTemplates { + if section.Name == "server-transform-helper" { + require.NoError(t, section.Write(&code)) + } + } + } + testutil.AssertGo( + t, + "testdata/golden/transform_helper_sibling-declarations.go.golden", + codegen.FormatTestCode(t, "package server\n"+code.String()), + ) +} + +func TestTransformHelpersShareExactPackageDeclaration(t *testing.T) { + root := expr.RunDSL(t, sharedTransformHelperDSL) + plan := linkedHTTPPlanForRoot(t, root) + service := plan.services.Get("SharedHelpers") + requiredName, optionalName := transformHelperNamesByRequired(t, service.serverWireTypes) + require.Contains(t, service.Endpoint("First").Payload.Request.PayloadInit.ServerCode, requiredName+"(body.Child)") + require.Contains(t, service.Endpoint("Second").Payload.Request.PayloadInit.ServerCode, requiredName+"(body.Child)") + require.Contains(t, service.Endpoint("Optional").Payload.Request.PayloadInit.ServerCode, optionalName+"(body.Child)") + require.NotContains(t, service.Endpoint("Optional").Payload.Request.PayloadInit.ServerCode, requiredName+"(body.Child)") + var ( + code bytes.Buffer + count int + ) + for _, file := range plan.ServerFiles() { + for _, section := range file.SectionTemplates { + if section.Name != "server-transform-helper" { + continue + } + count++ + require.NoError(t, section.Write(&code)) + } + } + require.Equal(t, 2, count) + testutil.AssertGo( + t, + "testdata/golden/transform_helper_shared-declarations.go.golden", + codegen.FormatTestCode(t, "package server\n"+code.String()), + ) +} + +func TestJSONRPCTransformHelpersUseHTTPPackageDeclarations(t *testing.T) { + root := expr.RunDSL(t, sharedJSONRPCTransformHelperDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + serviceData := plans[0].services.Get("SharedHelpers") + requiredName, optionalName := transformHelperNamesByRequired(t, serviceData.serverWireTypes) + snapshot, ok := plans[0].JSONRPCService("SharedHelpers") + require.True(t, ok) + require.Contains(t, snapshot.Endpoints[0].Payload.Request.PayloadInit.ServerCode, requiredName+"(body.Child)") + require.Contains(t, snapshot.Endpoints[1].Payload.Request.PayloadInit.ServerCode, requiredName+"(body.Child)") + require.Contains(t, snapshot.Endpoints[2].Payload.Request.PayloadInit.ServerCode, optionalName+"(body.Child)") + require.NotContains(t, snapshot.Endpoints[2].Payload.Request.PayloadInit.ServerCode, requiredName+"(body.Child)") + + first := jsonRPCTransformHelpers(snapshot.ServerCodecFile()) + second := jsonRPCTransformHelpers(snapshot.ServerCodecFile()) + require.Len(t, first, 2) + require.Len(t, second, 2) + require.ElementsMatch(t, []string{requiredName, optionalName}, []string{first[0].Name, first[1].Name}) + first[0].Name = "changed" + fresh := jsonRPCTransformHelpers(snapshot.ServerCodecFile()) + require.Equal(t, second[0].Name, fresh[0].Name) + require.NotEqual(t, first[0].Name, fresh[0].Name) +} + +// jsonRPCTransformHelpers returns the copied conversion functions from one +// JSON-RPC codec file so the test can change one copy without changing another. +func jsonRPCTransformHelpers(file *codegen.File) []*jsonRPCTransformFunctionData { + var helpers []*jsonRPCTransformFunctionData + for _, section := range file.SectionTemplates { + if section.Name != "server-transform-helper" { + continue + } + helpers = append(helpers, section.Data.(*jsonRPCTransformFunctionData)) + } + return helpers +} + +// transformHelperNamesByRequired returns the two function names from the test +// catalog according to whether they accept a missing source value. +func transformHelperNamesByRequired(t *testing.T, catalog *wireTypeCatalog) (string, string) { + t.Helper() + var required, optional string + for _, helper := range catalog.transformHelpers { + if helper.identity.required { + required = helper.declaration.Name() + } else { + optional = helper.declaration.Name() + } + } + require.NotEmpty(t, required) + require.NotEmpty(t, optional) + return required, optional +} + +// sharedTransformHelperDSL uses one named child in two required fields and one +// optional field so functions share only when missing values behave the same. +func sharedTransformHelperDSL() { + sharedTransformHelperDesign(false) +} + +// sharedJSONRPCTransformHelperDSL applies the same service types to JSON-RPC. +func sharedJSONRPCTransformHelperDSL() { + sharedTransformHelperDesign(true) +} + +// conciseTransformHelperDSL names the service and nested type like a normal +// application so generated functions should use those public names directly. +func conciseTransformHelperDSL() { + winery := dsl.ResultType("application/vnd.transform-helper.winery", func() { + dsl.TypeName("Winery") + dsl.Attribute("name", dsl.String) + dsl.Required("name") + }) + bottle := dsl.Type("Bottle", func() { + dsl.Attribute("winery", winery) + dsl.Required("winery") + }) + dsl.Service("Storage", func() { + dsl.Method("Create", func() { + dsl.Payload(bottle) + dsl.HTTP(func() { + dsl.POST("/") + }) + }) + }) +} + +// sharedTransformHelperDesign creates the service used to test normal HTTP and +// JSON-RPC package generation. +func sharedTransformHelperDesign(jsonrpc bool) { + child := dsl.Type("SharedChild", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("SharedHelpers", func() { + for _, method := range []struct { + name string + path string + required bool + }{ + {name: "First", path: "/first", required: true}, + {name: "Second", path: "/second", required: true}, + {name: "Optional", path: "/optional"}, + } { + dsl.Method(method.name, func() { + dsl.Payload(func() { + dsl.Attribute("child", child) + if method.required { + dsl.Required("child") + } + }) + if jsonrpc { + dsl.JSONRPC(func() {}) + } else { + dsl.HTTP(func() { + dsl.POST(method.path) + }) + } + }) + } + }) +} + +// manyDistinctTransformChildren builds one object conversion with more helper +// functions than fit in one byte. Every child requests the same preferred type +// name but has a different field, so declaration ordering must use its complete +// source and target type identity. +func manyDistinctTransformChildren(count int, reverse bool) (*expr.AttributeExpr, *expr.AttributeExpr) { + sourceObject := make(expr.Object, 0, count) + targetObject := make(expr.Object, 0, count) + required := make([]string, count) + for position := range count { + index := position + if reverse { + index = count - position - 1 + } + field := fmt.Sprintf("field_%d", index) + value := fmt.Sprintf("value_%d", index) + sourceChild := &expr.UserTypeExpr{ + TypeName: "Child", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: value, Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + } + targetChild := &expr.UserTypeExpr{ + TypeName: "Child", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: value, Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + } + sourceObject = append(sourceObject, &expr.NamedAttributeExpr{Name: field, Attribute: &expr.AttributeExpr{Type: sourceChild}}) + targetObject = append(targetObject, &expr.NamedAttributeExpr{Name: field, Attribute: &expr.AttributeExpr{Type: targetChild}}) + required[index] = field + } + return &expr.AttributeExpr{ + Type: &sourceObject, + Validation: &expr.ValidationExpr{Required: required}, + }, &expr.AttributeExpr{ + Type: &targetObject, + Validation: &expr.ValidationExpr{Required: append([]string(nil), required...)}, + } +} + +// plannedTransformHelperNames returns each distinct child field and the helper +// name assigned to its conversion. +func plannedTransformHelperNames(t *testing.T, reverse bool) map[string]string { + t.Helper() + source, target := manyDistinctTransformChildren(3, reverse) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy) + catalog.collectTransform(source, target, "marshal", "ordered helpers", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + linkTestWireTypeCatalog(t, generation, catalog) + + names := make(map[string]string, len(catalog.transformHelpers)) + for _, helper := range catalog.transformHelpers { + object := expr.AsObject(helper.identity.source.attribute.Type) + names[(*object)[0].Name] = helper.declaration.Name() + } + return names +} + +// plannedMatchingTransforms returns two independent plans whose child helpers +// share one declaration in the generated package. +func plannedMatchingTransforms( + t *testing.T, +) (*wireTypeCatalog, wireTransformHandle, wireTransformHandle) { + t.Helper() + source, target := manyDistinctTransformChildren(1, false) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy) + layout := wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + } + first := catalog.collectTransform(source, target, "marshal", "first", layout) + second := catalog.collectTransform(source, target, "marshal", "second", layout) + linkTestWireTypeCatalog(t, generation, catalog) + return catalog, first, second +} + +// renderTestTransform renders one service-to-wire conversion using the given +// service package qualifier. +func renderTestTransform( + catalog *wireTypeCatalog, + handle wireTransformHandle, + servicePackage string, +) (string, []*codegen.TransformFunctionData, error) { + serviceContext := codegen.NewAttributeContext(false, false, true, servicePackage, codegen.NewNameScope()) + wireContext := jsonBodyContext(catalog, catalog.scope, true, false) + return catalog.renderTransform(handle, handle.record.target, "source", "target", serviceContext, wireContext) +} + +// testServicePackage is the retained service package used by direct wire +// catalog tests. +func testServicePackage() codegen.ImportSpec { + return codegen.ImportSpec{Name: "inventory", Path: "generated.local/gen/inventory"} +} + +// plannedTransformErrorService returns an unlinked plan with request and +// response conversions that tests can replace with an invalid handle. +func plannedTransformErrorService( + t *testing.T, +) (*expr.RootExpr, *codegen.Generation, *service.Plan, *Plan) { + t.Helper() + root := expr.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + result := dsl.Type("Result", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("transform errors", func() { + dsl.Method("show", func() { + dsl.Payload(payload) + dsl.Result(result) + dsl.HTTP(func() { + dsl.POST("/show") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + return root, generation, servicePlan, plans[0] +} diff --git a/http/codegen/typedef.go b/http/codegen/typedef.go index 7afa972004..350b0e1ce9 100644 --- a/http/codegen/typedef.go +++ b/http/codegen/typedef.go @@ -26,12 +26,12 @@ import ( func goTypeDef(scope *codegen.NameScope, att *expr.AttributeExpr, ptr, useDefault bool) string { ctx := codegen.NewAttributeContext(ptr, false, useDefault, "", scope) ctx.UnionPointer = true - return goTypeDefForContext(scope, att, ctx) + return goTypeDefForContext(att, ctx) } // goTypeDefForContext recursively renders an HTTP body type using the same // field representation consulted by transport conversion and validation. -func goTypeDefForContext(scope *codegen.NameScope, att *expr.AttributeExpr, ctx *codegen.AttributeContext) string { +func goTypeDefForContext(att *expr.AttributeExpr, ctx *codegen.AttributeContext) string { switch actual := att.Type.(type) { case expr.Primitive: if t, _ := codegen.GetMetaType(att); t != "" { @@ -39,17 +39,17 @@ func goTypeDefForContext(scope *codegen.NameScope, att *expr.AttributeExpr, ctx } return codegen.GoNativeTypeName(actual) case *expr.Array: - d := goTypeDefForContext(scope, actual.ElemType, ctx) - if expr.IsObject(actual.ElemType.Type) { + d := goTypeDefForContext(actual.ElemType, ctx) + if expr.IsObject(actual.ElemType.Type) || ctx.IsArrayElementPointer(actual) { d = "*" + d } return "[]" + d case *expr.Map: - keyDef := goTypeDefForContext(scope, actual.KeyType, ctx) + keyDef := goTypeDefForContext(actual.KeyType, ctx) if expr.IsObject(actual.KeyType.Type) { keyDef = "*" + keyDef } - elemDef := goTypeDefForContext(scope, actual.ElemType, ctx) + elemDef := goTypeDefForContext(actual.ElemType, ctx) if expr.IsObject(actual.ElemType.Type) { elemDef = "*" + elemDef } @@ -67,7 +67,7 @@ func goTypeDefForContext(scope *codegen.NameScope, att *expr.AttributeExpr, ctx ) { fn = codegen.GoifyAtt(at, name, true) - tdef = goTypeDefForContext(scope, at, ctx) + tdef = goTypeDefForContext(at, ctx) if ctx.IsFieldPointer(name, att) { tdef = "*" + tdef } @@ -93,7 +93,7 @@ func goTypeDefForContext(scope *codegen.NameScope, att *expr.AttributeExpr, ctx ss = append(ss, "}") return strings.Join(ss, "\n") case expr.UserType, *expr.Union: - return scope.GoTypeName(att) + return ctx.Scope.Name(att, ctx.Pkg(att), ctx.Pointer, ctx.UseDefault) default: panic(fmt.Sprintf("unknown data type %T", actual)) // bug } diff --git a/http/codegen/types.go b/http/codegen/types.go index b6eb01db67..20fbccd869 100644 --- a/http/codegen/types.go +++ b/http/codegen/types.go @@ -1,3 +1,5 @@ +// This file renders HTTP request and response types per service and transport +// side, using imports attached to that exact generated type file. package codegen import ( @@ -7,20 +9,20 @@ import ( "goa.design/goa/v3/expr" ) -// ServerTypeFiles returns the HTTP transport type files. -func ServerTypeFiles(genpkg string, data *ServicesData) []*codegen.File { +// serverTypeFiles builds the server request and response types read by Plan.Link. +func serverTypeFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(data.Expressions.Services)) for i, svc := range data.Expressions.Services { - fw[i] = typesFile(genpkg, svc, true, data) + fw[i] = addPlannedFileImports(typesFile(svc, true, data), data) } return fw } -// ClientTypeFiles returns the HTTP transport client types files. -func ClientTypeFiles(genpkg string, data *ServicesData) []*codegen.File { +// clientTypeFiles builds the client request and response types read by Plan.Link. +func clientTypeFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(data.Expressions.Services)) for i, svc := range data.Expressions.Services { - fw[i] = typesFile(genpkg, svc, false, data) + fw[i] = addPlannedFileImports(typesFile(svc, false, data), data) } return fw } @@ -49,7 +51,7 @@ func ClientTypeFiles(genpkg string, data *ServicesData) []*codegen.File { // // - Response body fields (if the body is a struct) and header variables hold // pointers when not required and have no default value. -func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *codegen.File { +func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *codegen.File { var ( data = services.Get(svc.Name()) svcName = data.Service.PathName @@ -78,22 +80,23 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser validateSection = "client-validate" bodyInitT = clientBodyInitT } + unionTypes := data.wireTypes(svr).unionTypes() path := filepath.Join(codegen.Gendir, services.dir(), svcName, side, "types.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) imports := []*codegen.ImportSpec{ {Path: "encoding/json"}, {Path: "fmt"}, {Path: "unicode/utf8"}, - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, + services.ServiceImport(outputPackage, svc.Name()), } - if len(data.UnionTypes) > 0 { - imports = append(imports, &codegen.ImportSpec{Path: "bytes"}) + if serviceHasViewedResult(data, nil) { + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } - views := &codegen.ImportSpec{Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg} - if svr { - imports = append(imports, codegen.GoaImport(""), views) - } else { - imports = append(imports, views, codegen.GoaImport("")) + if len(unionTypes) > 0 { + imports = append(imports, &codegen.ImportSpec{Path: "bytes"}) } + imports = append(imports, codegen.GoaImport("")) header := codegen.Header(svc.Name()+" "+services.label()+" "+side+" types", side, imports) var ( @@ -102,46 +105,43 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser sections = []*codegen.SectionTemplate{header} - // seen tracks the body types already emitted in this file. Server - // types are deduplicated by type name because distinct - // endpoint-scoped composite wrappers may share a structural - // reference, client types by reference because structurally - // identical types are decoded interchangeably. - seen = make(map[string]struct{}) + // seen records each generated type declaration already written to this + // file. Two declarations may have similar Go type text, so the declaration + // itself decides whether another definition is needed. + seen = make(map[*wireTypeRecord]struct{}) seenInits = make(map[string]struct{}) - seenValidated = make(map[string]struct{}) + seenValidated = make(map[*wireTypeRecord]struct{}) ) - key := func(td *TypeData) string { - if svr { - return td.Name - } - return td.Ref - } // addDecl emits the type declaration section if the type has a // definition. addDecl := func(name string, td *TypeData) { - if td.Def != "" { + if td.declaration == nil || td.Def == "" { + return + } + if _, ok := seen[td.declaration]; ok { + return + } + seen[td.declaration] = struct{}{} + declaration := td.declaration.data + if declaration != nil { sections = append(sections, &codegen.SectionTemplate{ Name: name, Source: httpTemplates.Read(typeDeclT), - Data: td, + Data: declaration, }) } } - // addValidated records the type for validation method generation. Client - // types are deduplicated by name; server types rely on the body type - // dedup performed by the callers. + // addValidated records each validation helper declared in this generated + // package once. addValidated := func(td *TypeData) { - if td.ValidateDef == "" { + if td.declaration == nil || td.ValidateDef == "" { return } - if !svr { - if _, ok := seenValidated[td.Name]; ok { - return - } - seenValidated[td.Name] = struct{}{} + if _, ok := seenValidated[td.declaration]; ok { + return } - validatedTypes = append(validatedTypes, td) + seenValidated[td.declaration] = struct{}{} + validatedTypes = append(validatedTypes, td.declaration.data) } // request body types @@ -150,7 +150,7 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser var body, wsPayload *TypeData if svr { body = adata.Payload.Request.ServerBody - if adata.ServerWebSocket != nil && !adata.IsJSONRPC { + if adata.ServerWebSocket != nil { wsPayload = adata.ServerWebSocket.Payload } } else { @@ -163,12 +163,6 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser if td == nil { continue } - if !svr { - if _, ok := seen[td.Ref]; ok { - continue - } - seen[td.Ref] = struct{}{} - } name := requestBodySection if i == 1 { name = wsPayloadSection @@ -194,22 +188,50 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser bodies := resp.ServerBody if !svr { bodies = nil - if resp.ClientBody != nil { + if len(resp.ViewedRepresentations) > 0 { + for _, representation := range resp.ViewedRepresentations { + bodies = append(bodies, representation.ClientBody) + } + } else if resp.ClientBody != nil { bodies = []*TypeData{resp.ClientBody} } } for _, td := range bodies { - if _, ok := seen[key(td)]; ok { + if td == nil { continue } - seen[key(td)] = struct{}{} addDecl(responseBodySection, td) if td.Init != nil { - initData = append(initData, td.Init) + if _, ok := seenInits[td.Init.Name]; !ok { + seenInits[td.Init.Name] = struct{}{} + initData = append(initData, td.Init) + } } addValidated(td) } } + if !adata.HasMixedResults || adata.SSE == nil || adata.SSE.Response == nil { + continue + } + var bodies []*TypeData + if svr { + bodies = adata.SSE.Response.ServerBody + } else if adata.SSE.Response.ClientBody != nil { + bodies = []*TypeData{adata.SSE.Response.ClientBody} + } + for _, td := range bodies { + if td == nil { + continue + } + addDecl(responseBodySection, td) + if td.Init != nil { + if _, ok := seenInits[td.Init.Name]; !ok { + seenInits[td.Init.Name] = struct{}{} + initData = append(initData, td.Init) + } + } + addValidated(td) + } } // error body types @@ -225,18 +247,12 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser } } for _, td := range bodies { - if _, ok := seen[key(td)]; ok { - continue - } - // Server error body types without a definition are not - // marked as emitted: their endpoint-scoped constructors - // and validations are collected for every occurrence. - if !svr || td.Def != "" { - seen[key(td)] = struct{}{} - } addDecl(errorBodySection, td) if td.Init != nil { - initData = append(initData, td.Init) + if _, ok := seenInits[td.Init.Name]; !ok { + seenInits[td.Init.Name] = struct{}{} + initData = append(initData, td.Init) + } } addValidated(td) } @@ -250,18 +266,12 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser atts = data.ClientBodyAttributeTypes } for _, td := range atts { - if !svr { - if _, ok := seen[td.Ref]; ok { - continue - } - seen[td.Ref] = struct{}{} - } addDecl(attributeSection, td) addValidated(td) } // union sum types - for _, u := range data.UnionTypes { + for _, u := range unionTypes { sections = append(sections, &codegen.SectionTemplate{ Name: unionSection, Source: httpTemplates.Read(unionTypeT), @@ -295,10 +305,16 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser seenResultInits := make(map[string]struct{}) for _, adata := range data.Endpoints { for _, resp := range adata.Result.Responses { - if init := resp.ResultInit; init != nil { - if _, ok := seenResultInits[init.Name]; !ok { - seenResultInits[init.Name] = struct{}{} - sections = append(sections, resultInitSection("client-result-init", init)) + inits := []*InitData{resp.ResultInit} + for _, representation := range resp.ViewedRepresentations { + inits = append(inits, representation.ResultInit) + } + for _, init := range inits { + if init != nil { + if _, ok := seenResultInits[init.Name]; !ok { + seenResultInits[init.Name] = struct{}{} + sections = append(sections, resultInitSection("client-result-init", init)) + } } } } diff --git a/http/codegen/validation_path_test.go b/http/codegen/validation_path_test.go new file mode 100644 index 0000000000..1b8c84555d --- /dev/null +++ b/http/codegen/validation_path_test.go @@ -0,0 +1,198 @@ +// This file verifies that generated HTTP validators keep complete error paths +// while reusing named validators for nested and recursive values. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + gencodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestHTTPValidationPathsUseGeneratedCalls(t *testing.T) { + root := expr.RunDSL(t, recursiveValidationPathDSL) + code := renderedFiles(t, linkedHTTPPlanForRoot(t, root).ServerTypeFiles()) + + require.Contains(t, code, `validateNodeRequestBody(body.First, "body.first")`) + require.Contains(t, code, `validateNodeRequestBody(body.Second, "body.second")`) + require.Contains(t, code, `validateNodeRequestBody(body.Next, "body.next")`) + require.Contains(t, code, `validateNodeRequestBody(body.Next, path+".next")`) + require.Contains(t, code, `validateNodeRequestBody(e, path+".children[*]")`) + require.Contains(t, code, `validateNodeRequestBody(v, path+".children_by_name[key]")`) + require.Contains(t, code, `goa.InvalidLengthError("body.value"`) + require.Contains(t, code, `goa.InvalidLengthError(path+".value"`) + require.Equal(t, 1, strings.Count(code, "func validateNodeRequestBody(")) + require.NotContains(t, code, "fmt.Sprintf") +} + +func TestHTTPValidationPathsKeepMutualRecursion(t *testing.T) { + root := expr.RunDSL(t, mutualValidationPathDSL) + code := renderedFiles(t, linkedHTTPPlanForRoot(t, root).ServerTypeFiles()) + + require.Contains(t, code, `validateLeftRequestBody(body.Left, "body.left")`) + require.Contains(t, code, `validateRightRequestBody(body.Right, path+".right")`) + require.Contains(t, code, `validateLeftRequestBody(body.Left, path+".left")`) + require.Equal(t, 1, strings.Count(code, "func validateLeftRequestBody(")) + require.Equal(t, 1, strings.Count(code, "func validateRightRequestBody(")) +} + +func TestHTTPValidationPathsOmitUnusedNestedHelper(t *testing.T) { + root := expr.RunDSL(t, unusedClientNestedValidationDSL) + code := renderedFiles(t, linkedHTTPPlanForRoot(t, root).ClientTypeFiles()) + + require.Contains(t, code, "func ValidateChildRequestBody(") + require.NotContains(t, code, "func validateChildRequestBody(") +} + +func TestHTTPValidationPathsOmitUnusedViewedSSENestedHelper(t *testing.T) { + root := expr.RunDSL(t, viewedSSENestedValidationDSL) + var plan *Plan + require.NotPanics(t, func() { + plan = linkedHTTPPlanForRoot(t, root) + }) + types := renderedFiles(t, plan.ClientTypeFiles()) + serviceFiles, err := service.Files(plan.servicePlan) + require.NoError(t, err) + views := renderedFiles(t, serviceFiles) + + require.NotContains(t, types, "func validateProfile(") + require.Contains(t, views, `goa.ValidatePattern("result.code"`) +} + +func TestHTTPValidationPathsOmitUnusedClientArrayElementValidator(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadBodyPrimitiveArrayUserRequiredDSL) + generation, err := gencodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + + planned := plans[0].wireTypes[root.API.HTTP.Services[0]] + var clientElement, serverElement *wireTypeRecord + for _, record := range planned.client.records { + if record.identity.preferred == "PayloadType" { + clientElement = record + break + } + } + for _, record := range planned.server.records { + if record.identity.preferred == "PayloadType" { + serverElement = record + break + } + } + require.NotNil(t, clientElement) + require.False(t, clientElement.needsNestedCall) + require.Nil(t, clientElement.nestedValidator) + require.NotNil(t, serverElement) + require.True(t, serverElement.needsNestedCall) + require.NotNil(t, serverElement.nestedValidator) +} + +// recursiveValidationPathDSL defines one named type used by two fields and by +// its own object, array, and map fields. +func recursiveValidationPathDSL() { + node := Type("Node", func() { + Attribute("value", String, func() { + MinLength(1) + }) + Attribute("next", "Node") + Attribute("children", ArrayOf("Node")) + Attribute("children_by_name", MapOf(String, "Node")) + }) + payload := Type("Payload", func() { + Attribute("first", node) + Attribute("second", node) + }) + Service("RecursiveValidation", func() { + Method("Check", func() { + Payload(payload) + HTTP(func() { + POST("/check") + }) + }) + }) +} + +// mutualValidationPathDSL defines two named types that refer to each other. +func mutualValidationPathDSL() { + left := Type("Left", func() { + Attribute("right", "Right") + }) + Type("Right", func() { + Attribute("code", String, func() { + Pattern("^[a-z]+$") + }) + Attribute("left", "Left") + }) + payload := Type("MutualPayload", func() { + Attribute("left", left) + }) + Service("MutualValidation", func() { + Method("Check", func() { + Payload(payload) + HTTP(func() { + POST("/check") + }) + }) + }) +} + +// unusedClientNestedValidationDSL defines a child validator whose client +// request body has no generated validation call to that child. +func unusedClientNestedValidationDSL() { + child := Type("Child", func() { + Attribute("code", String, func() { + Pattern("^[a-z]+$") + }) + }) + payload := Type("UnusedNestedPayload", func() { + Attribute("child", child) + }) + Service("UnusedNestedValidation", func() { + Method("Check", func() { + Payload(payload) + HTTP(func() { + POST("/check") + }) + }) + }) +} + +// viewedSSENestedValidationDSL defines a viewed event whose views package +// validates one nested named value. +func viewedSSENestedValidationDSL() { + profile := Type("Profile", func() { + Attribute("code", String, func() { + Pattern("^[a-z]+$") + }) + }) + event := ResultType("application/vnd.viewed-sse-nested-validation", func() { + TypeName("ViewedSSENestedValidation") + Attribute("profile", profile) + Required("profile") + View("summary", func() { + Attribute("profile") + }) + View("detailed", func() { + Attribute("profile") + }) + }) + Service("Viewed SSE Nested Validation", func() { + Method("Watch", func() { + StreamingResult(event) + HTTP(func() { + GET("/watch") + ServerSentEvents() + }) + }) + }) +} diff --git a/http/codegen/viewed_sse_test.go b/http/codegen/viewed_sse_test.go new file mode 100644 index 0000000000..abe0e22e93 --- /dev/null +++ b/http/codegen/viewed_sse_test.go @@ -0,0 +1,402 @@ +// This file verifies that an HTTP server-sent event encodes and decodes the +// body selected for its result view. +package codegen + +import ( + "path" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestViewedSSEServerLocksFirstView verifies that a request-scoped stream +// rejects a later representation before encoding a body under the first view. +func TestViewedSSEServerLocksFirstView(t *testing.T) { + root := expr.RunDSL(t, viewedSSEDSL) + plan := linkedHTTPPlanForRoot(t, root) + code := renderedFile(t, plan.ServerFiles()) + + require.Contains(t, code, `if s.sentView != "" && view != s.sentView`) + require.Contains(t, code, `goa.InvalidEnumValueError("view", view, []any{s.sentView})`) + require.Less(t, + strings.Index(code, `if s.sentView != "" && view != s.sentView`), + strings.Index(code, `s.once.Do(func()`), + ) + require.Less(t, strings.Index(code, "res := "), strings.Index(code, `s.once.Do(func()`)) + require.Less(t, strings.Index(code, "body := "), strings.Index(code, `s.once.Do(func()`)) + require.Less(t, strings.Index(code, `s.sentView = view`), strings.Index(code, `s.once.Do(func()`)) + require.Less(t, strings.Index(code, `s.once.Do(func()`), strings.Index(code, `s.attempted = true`)) +} + +// TestViewedSSEClientReconstructsCollections verifies collection events decode +// the selected body, run its constructor and validator, and return the service +// method's result type. +func TestViewedSSEClientReconstructsCollections(t *testing.T) { + root := expr.RunDSL(t, viewedSSECollectionDSL) + plan := linkedHTTPPlanForRoot(t, root) + code := renderedFile(t, plan.ClientFiles()) + + require.Contains(t, code, "switch view {") + require.Contains(t, code, "Decode(&body)") + require.Contains(t, code, "projected := New") + require.Contains(t, code, "views.Validate") + require.Contains(t, code, "result := viewedssecollection.New") + require.NotContains(t, code, `partial_sse_parse`) +} + +// TestViewedSSEUsesConfiguredDataField verifies the server encodes and the +// client decodes only the result field selected as the event data. +func TestViewedSSEUsesConfiguredDataField(t *testing.T) { + root := expr.RunDSL(t, viewedSSEDataFieldDSL) + plan := linkedHTTPPlanForRoot(t, root) + client := renderedFile(t, plan.ClientFiles()) + server := renderedFile(t, plan.ServerFiles()) + + require.Contains(t, client, "value := dataContent") + require.Contains(t, client, "body.Data = &value") + require.Contains(t, client, "projected := New") + require.Contains(t, client, "views.Validate") + require.Contains(t, server, "data = string(body.Data)") + require.NotContains(t, server, "var payload any") +} + +// TestViewedSSERebuildsRequiredResponseFields checks that the client reads the +// event id, event type, and data before it calls the generated result +// constructor and validator. +func TestViewedSSERebuildsRequiredResponseFields(t *testing.T) { + root := expr.RunDSL(t, viewedSSERequiredFieldsDSL) + plan := linkedHTTPPlanForRoot(t, root) + client := renderedFile(t, plan.ClientFiles()) + + for _, assignment := range []string{ + "body.ID = event.ID", + "body.Kind = event.Kind", + "value := dataContent", + "body.Data = &value", + } { + require.Contains(t, client, assignment) + require.Less(t, strings.Index(client, assignment), strings.Index(client, "projected := New")) + } + require.Less(t, strings.Index(client, "projected := New"), strings.Index(client, "views.Validate")) +} + +// TestViewedClientsUseAssignedValidator checks that each HTTP client calls the +// validator name chosen for the service views package when another declaration +// requests the validator's preferred spelling. +func TestViewedClientsUseAssignedValidator(t *testing.T) { + root := expr.RunDSL(t, viewedClientValidatorCollisionDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + viewsPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "viewed_validator", "views")) + require.NoError(t, err) + preferred := "ValidateViewedClientCollision" + require.NoError(t, viewsPackage.DeclareName(codegen.NewExactName(codegen.NameFunction, preferred))) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + var declaration *codegen.NameDeclaration + for _, endpoint := range plans[0].services.Get("Viewed Validator").Endpoints { + if endpoint.Method.ViewedResult != nil { + declaration = endpoint.Method.ViewedResult.Validate.Declaration + break + } + } + require.NotNil(t, declaration) + require.NotEqual(t, preferred, declaration.Name()) + client := renderedFiles(t, plans[0].ClientFiles()) + require.GreaterOrEqual(t, strings.Count(client, "."+declaration.Name()+"("), 3) + require.NotContains(t, client, "."+preferred+"(") +} + +// TestViewedSSESoleViewIsFixed checks that the generated service supplies its +// only legal view, so the HTTP response needs no view selector. +func TestViewedSSESoleViewIsFixed(t *testing.T) { + plan := linkedHTTPPlan(t, viewedSSESoleViewDSL) + viewed, ok := plan.ViewedResult("Viewed SSE Sole View", "Watch") + require.True(t, ok) + + require.False(t, viewed.Variable) + require.Equal(t, expr.DefaultView, viewed.FixedView) + require.Len(t, viewed.Representations, 1) +} + +// TestViewedResultConstructorsUsePackageDeclarations verifies Go-equivalent +// view names receive distinct stable functions and every definition and call +// uses the same package-owned declaration. +func TestViewedResultConstructorsUsePackageDeclarations(t *testing.T) { + root := expr.RunDSL(t, viewedSSEConstructorCollisionDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + response := endpoint.Responses[0] + retained, ok := plans[0].ViewedResult("Viewed SSE Collision", "Watch") + require.True(t, ok) + require.GreaterOrEqual(t, len(retained.Representations), 2) + names := make(map[string]struct{}, len(retained.Representations)) + collidingNames := make(map[string]string, 2) + definitions := renderedFiles(t, plans[0].ClientTypeFiles()) + calls := renderedFile(t, plans[0].ClientFiles()) + for _, representation := range retained.Representations { + declaration := plans[0].constructors[viewedConstructorKey{ + endpoint: endpoint, + response: response, + view: representation.View, + }] + require.Same(t, declaration, representation.ResultInit.Declaration) + name := declaration.Name() + names[name] = struct{}{} + if representation.View == "foo-bar" || representation.View == "foo bar" { + collidingNames[representation.View] = name + } + require.Contains(t, definitions, "func "+name+"(") + require.Contains(t, calls, name+"(") + } + require.Len(t, names, len(retained.Representations)) + require.NotEqual(t, collidingNames["foo-bar"], collidingNames["foo bar"]) +} + +// renderedFile renders the generated sse.go file. +func renderedFile(t *testing.T, files []*codegen.File) string { + t.Helper() + for _, file := range files { + if strings.HasSuffix(file.Path, "sse.go") { + return renderedFiles(t, []*codegen.File{file}) + } + } + t.Error("generated sse.go file was not planned") + return "" +} + +// renderedFiles renders every planned section so tests compare generated Go +// definitions and calls rather than template text. +func renderedFiles(t *testing.T, files []*codegen.File) string { + t.Helper() + var rendered strings.Builder + for _, file := range files { + for _, section := range file.SectionTemplates[1:] { + rendered.WriteString(codegen.SectionCode(t, section)) + } + } + return rendered.String() +} + +// viewedSSEType defines two legal branches with different body shapes. +func viewedSSEType(name string) *expr.ResultTypeExpr { + return dsl.ResultType("application/vnd."+name, func() { + dsl.TypeName(name) + dsl.Attribute("id", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { dsl.Attribute("id") }) + dsl.View("detailed", func() { + dsl.Attribute("id") + dsl.Attribute("detail") + }) + }) +} + +// viewedSSEDSL defines a variable-view object stream. +func viewedSSEDSL() { + event := viewedSSEType("ViewedSSEEvent") + dsl.Service("Viewed SSE", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} + +// viewedSSECollectionDSL defines a variable-view collection stream. +func viewedSSECollectionDSL() { + event := viewedSSEType("ViewedSSECollectionEvent") + dsl.Service("Viewed SSE Collection", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(dsl.CollectionOf(event)) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} + +// viewedSSEDataFieldDSL defines a variable-view stream whose data line carries +// one configured result field. +func viewedSSEDataFieldDSL() { + event := dsl.ResultType("application/vnd.viewed-sse-data", func() { + dsl.TypeName("ViewedSSEData") + dsl.Attribute("data", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("data") + dsl.View("summary", func() { dsl.Attribute("data") }) + dsl.View("detailed", func() { + dsl.Attribute("data") + dsl.Attribute("detail") + }) + }) + dsl.Service("Viewed SSE Data", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("data") + }) + }) + }) +} + +// viewedSSEPrimitiveAliasDataFieldDSL defines a viewed stream whose data line +// carries a required field declared with a named string type. +func viewedSSEPrimitiveAliasDataFieldDSL() { + text := dsl.Type("ViewedEventText", dsl.String) + event := dsl.ResultType("application/vnd.viewed-sse-alias-data", func() { + dsl.TypeName("ViewedSSEAliasData") + dsl.Attribute("data", text) + dsl.Attribute("detail", dsl.String) + dsl.Required("data") + dsl.View("summary", func() { dsl.Attribute("data") }) + dsl.View("detailed", func() { + dsl.Attribute("data") + dsl.Attribute("detail") + }) + }) + dsl.Service("Viewed SSE Alias Data", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("data") + }) + }) + }) +} + +// viewedSSERequiredFieldsDSL maps required result fields across every input a +// streamed HTTP response can carry. +func viewedSSERequiredFieldsDSL() { + event := dsl.ResultType("application/vnd.viewed-sse-required", func() { + dsl.TypeName("ViewedSSERequired") + dsl.Attribute("id", dsl.String) + dsl.Attribute("kind", dsl.String) + dsl.Attribute("data", dsl.String) + dsl.Required("id", "kind", "data") + for _, name := range []string{"summary", "detailed"} { + dsl.View(name, func() { + dsl.Attribute("id") + dsl.Attribute("kind") + dsl.Attribute("data") + }) + } + }) + dsl.Service("Viewed SSE Required", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("data", func() { + dsl.SSEEventID("id") + dsl.SSEEventType("kind") + }) + }) + }) + }) +} + +// viewedClientValidatorCollisionDSL exposes one viewed type through unary, +// server-sent event, and WebSocket responses. +func viewedClientValidatorCollisionDSL() { + result := dsl.ResultType("application/vnd.viewed-client-collision", func() { + dsl.TypeName("ViewedClientCollision") + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { dsl.Attribute("id") }) + dsl.View("detailed", func() { dsl.Attribute("id") }) + }) + dsl.Service("Viewed Validator", func() { + dsl.Method("Read", func() { + dsl.Result(result) + dsl.HTTP(func() { dsl.GET("/read") }) + }) + dsl.Method("Watch", func() { + dsl.StreamingResult(result) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + dsl.Method("Socket", func() { + dsl.StreamingResult(result) + dsl.HTTP(func() { dsl.GET("/socket") }) + }) + }) +} + +// viewedSSESoleViewDSL defines only Goa's default view. +func viewedSSESoleViewDSL() { + event := dsl.ResultType("application/vnd.viewed-sse-sole-view", func() { + dsl.TypeName("ViewedSSESoleView") + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View(expr.DefaultView, func() { dsl.Attribute("id") }) + }) + dsl.Service("Viewed SSE Sole View", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} + +// linkedHTTPPlan runs the same planning steps as the generator and returns the +// HTTP plan after all service and package names are available. +func linkedHTTPPlan(t *testing.T, design func()) *Plan { + t.Helper() + root := expr.RunDSL(t, design) + return linkedHTTPPlanForRoot(t, root) +} + +// viewedSSEConstructorCollisionDSL defines two view names that Goify maps to +// the same preferred constructor spelling. +func viewedSSEConstructorCollisionDSL() { + event := dsl.ResultType("application/vnd.viewed-sse-collision", func() { + dsl.TypeName("ViewedSSECollisionEvent") + dsl.Attribute("id", dsl.String) + dsl.View("foo-bar", func() { dsl.Attribute("id") }) + dsl.View("foo bar", func() { dsl.Attribute("id") }) + }) + dsl.Service("Viewed SSE Collision", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} diff --git a/http/codegen/websocket.go b/http/codegen/websocket.go index 3ff14dd79c..2858cb2c76 100644 --- a/http/codegen/websocket.go +++ b/http/codegen/websocket.go @@ -1,21 +1,34 @@ +// This file builds the values used to write WebSocket client and server files +// for streaming HTTP methods. package codegen import ( "fmt" "path/filepath" "slices" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" ) type ( + // connConfigurerData gives WebSocket code the type and constructor names for + // either the client or server package. + connConfigurerData struct { + *ServiceData + Declaration *codegen.NameDeclaration + InitDeclaration *codegen.NameDeclaration + } + // WebSocketData contains the data needed to render struct type that // implements the server and client stream interfaces. WebSocketData struct { - // VarName is the name of the struct. + // VarName is the stream implementation type name kept for existing plugins. + // + // Deprecated: Use VarDeclaration.Name() after planning so name collisions are handled. VarName string + // VarDeclaration is the generated Go type name used by the stream implementation. + VarDeclaration *codegen.NameDeclaration // Type is type of the stream (server or client). Type string // Interface is the fully qualified name of the interface that @@ -90,7 +103,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin ) md := ed.Method svc := sd.Service - svcctx := serviceContext(sd.Service.PkgName, sd.Service.Scope) + svcctx := sds.serviceTypeContext(sd, "server").Enter(e.MethodExpr.StreamingPayload) svrSendTypeName := ed.Result.Name svrSendTypeRef := ed.Result.Ref svrSendDesc := fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection.", md.ServerStream.SendName, svrSendTypeName, md.Name) @@ -99,9 +112,10 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin cliRecvWithContextDesc := fmt.Sprintf("%s reads instances of %q from the %q endpoint websocket connection with context.", md.ClientStream.RecvWithContextName, svrSendTypeName, md.Name) if e.MethodExpr.Stream == expr.ClientStreamKind || e.MethodExpr.Stream == expr.BidirectionalStreamKind { streamBody := sd.bodies.streaming(e) - svrRecvTypeName = sd.Scope.GoFullTypeName(e.MethodExpr.StreamingPayload, svc.PkgName) - svrRecvTypeRef = sd.Scope.GoFullTypeRef(e.MethodExpr.StreamingPayload, svc.PkgName) - svrPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, true, sd) + streamOwner := expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr) + svrRecvTypeName = svcctx.Scope.Name(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload), false, true) + svrRecvTypeRef = svcctx.Scope.Ref(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload)) + svrPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, wireStreamPayload, true, sd, streamOwner, streamOwner) if needInit(e.MethodExpr.StreamingPayload.Type) { body := streamBody.Type // generate constructor function to transform request body, @@ -113,15 +127,11 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin serverCode string err error ) - n := codegen.Goify(e.MethodExpr.Name, true) - p := codegen.Goify(svrPayload.Name, true) - // Raw payload object has type name prefixed with endpoint name. No need to - // prefix the type name again. - if strings.HasPrefix(p, n) { - name = fmt.Sprintf("New%s", p) - } else { - name = fmt.Sprintf("New%s%s", n, p) + declaration := sds.streamConstructors[e] + if declaration == nil { + panic(fmt.Sprintf("streaming payload constructor for %s.%s was not submitted", svc.Name, e.Name())) } + name = declaration.Name() desc = fmt.Sprintf("%s builds a %s service %s endpoint payload.", name, svc.Name, e.MethodExpr.Name) if body != expr.Empty { ref := "body" @@ -131,7 +141,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin var svcode string if ut, ok := body.(expr.UserType); ok { if val := ut.Attribute().Validation; val != nil { - httpctx := httpContext(sd.Scope, true, true) + httpctx := jsonBodyContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) svcode = codegen.ValidationCode(ut.Attribute(), ut, httpctx, true, expr.IsAlias(ut), false, "body") } } @@ -140,21 +150,20 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin AttributeData: &AttributeData{ Name: "payload", VarName: "body", - TypeName: sd.Scope.GoTypeName(streamBody), - TypeRef: sd.Scope.GoTypeRef(streamBody), + TypeName: svrPayload.VarName, + TypeRef: svrPayload.Ref, Type: streamBody.Type, Required: true, - // The example has always been computed from the - // request body, not the streaming body. - Example: sd.bodies.request(e).Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(streamBody, streamOwner), Validate: svcode, }, }} } if body != expr.Empty { var helpers []*codegen.TransformFunctionData - httpctx := httpContext(sd.Scope, true, true) - serverCode, helpers, err = marshal(streamBody, e.MethodExpr.StreamingPayload, "body", "v", httpctx, svcctx) + httpctx := jsonBodyContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + transforms := sd.transforms.requests[clientBodyConstructorKey{endpoint: e, role: wireStreamPayload}] + serverCode, helpers, err = sd.serverWireTypes.renderTransform(transforms.serverDecode, streamBody, "body", "v", httpctx, svcctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } @@ -163,21 +172,18 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin panic(err) // bug } svrPayload.Init = &InitData{ + Declaration: declaration, Name: name, Description: desc, ServerArgs: serverArgs, - ReturnTypeName: svc.Scope.GoFullTypeName(e.MethodExpr.StreamingPayload, svc.PkgName), - ReturnTypeRef: svc.Scope.GoFullTypeRef(e.MethodExpr.StreamingPayload, svc.PkgName), + ReturnTypeName: svcctx.Scope.Name(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload), false, true), + ReturnTypeRef: svcctx.Scope.Ref(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload)), ReturnIsStruct: expr.IsObject(e.MethodExpr.StreamingPayload.Type), - ReturnTypePkg: svc.PkgName, + ReturnTypePkg: svcctx.Pkg(e.MethodExpr.StreamingPayload), ServerCode: serverCode, } } - cliPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, false, sd) - if cliPayload != nil { - sd.ClientTypeNames[cliPayload.Name] = struct{}{} - sd.ServerTypeNames[cliPayload.Name] = struct{}{} - } + cliPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, wireStreamPayload, false, sd, streamOwner, streamOwner) if e.MethodExpr.Stream == expr.ClientStreamKind { svrSendDesc = fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection and closes the connection.", md.ServerStream.SendName, svrSendTypeName, md.Name) svrSendWithContextDesc = fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection with context and closes the connection.", md.ServerStream.SendWithContextName, svrSendTypeName, md.Name) @@ -240,12 +246,15 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin // websocketServerFile returns the file implementing the WebSocket server // streaming implementation if any. -func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func websocketServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasWebSocket(data) { return nil } svcName := data.Service.PathName + outputPath := filepath.Join(codegen.Gendir, "http", svcName, "server", "websocket.go") + outputPackage := generatedFileOutputPackage(services, outputPath) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s WebSocket server streaming", svc.Name()) imports := []*codegen.ImportSpec{ {Path: "context"}, @@ -256,7 +265,7 @@ func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *Ser {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, + services.ServiceImport(outputPackage, svc.Name()), } structSections := serverStructWSSections(data) wsSections := serverWSSections(data) @@ -266,19 +275,22 @@ func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *Ser sections = append(sections, wsSections...) return &codegen.File{ - Path: filepath.Join(codegen.Gendir, "http", svcName, "server", "websocket.go"), + Path: outputPath, SectionTemplates: sections, } } -// WebsocketClientFile returns the file implementing the WebSocket client +// websocketClientFile returns the file implementing the WebSocket client // streaming implementation if any. -func WebsocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func websocketClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasWebSocket(data) { return nil } svcName := data.Service.PathName + outputPath := filepath.Join(codegen.Gendir, "http", svcName, "client", "websocket.go") + outputPackage := generatedFileOutputPackage(services, outputPath) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s WebSocket client streaming", svc.Name()) imports := []*codegen.ImportSpec{ {Path: "context"}, @@ -289,8 +301,10 @@ func WebsocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *Ser {Path: "github.com/gorilla/websocket"}, codegen.GoaImport(""), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, + services.ServiceImport(outputPackage, svc.Name()), + } + if serviceHasViewedResult(data, IsWebSocketEndpoint) { + imports = append(imports, services.ViewImport(outputPackage, svc.Name())) } structSections := clientStructWSSections(data) wsSections := clientWSSections(data) @@ -300,7 +314,7 @@ func WebsocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *Ser sections = append(sections, wsSections...) return &codegen.File{ - Path: filepath.Join(codegen.Gendir, "http", svcName, "client", "websocket.go"), + Path: outputPath, SectionTemplates: sections, } } @@ -308,11 +322,12 @@ func WebsocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *Ser // serverStructWSSections return section templates that generate WebSocket // related struct type definitions for the server. func serverStructWSSections(data *ServiceData) []*codegen.SectionTemplate { + configurer := &connConfigurerData{data, data.ServerConnConfigurerDeclaration, data.ServerConnConfigurerInitDeclaration} var sections []*codegen.SectionTemplate sections = append(sections, &codegen.SectionTemplate{ Name: "server-websocket-conn-configurer-struct", Source: httpTemplates.Read(websocketConnConfigurerStructT), - Data: data, + Data: configurer, FuncMap: map[string]any{"isWebSocketEndpoint": IsWebSocketEndpoint}, }) for _, e := range data.Endpoints { @@ -331,11 +346,12 @@ func serverStructWSSections(data *ServiceData) []*codegen.SectionTemplate { // serverWSSections returns section templates that contain server WebSocket // specific code for the given service. func serverWSSections(data *ServiceData) []*codegen.SectionTemplate { + configurer := &connConfigurerData{data, data.ServerConnConfigurerDeclaration, data.ServerConnConfigurerInitDeclaration} var sections []*codegen.SectionTemplate sections = append(sections, &codegen.SectionTemplate{ Name: "server-websocket-conn-configurer-struct-init", Source: httpTemplates.Read(websocketConnConfigurerStructInitT), - Data: data, + Data: configurer, FuncMap: map[string]any{"isWebSocketEndpoint": IsWebSocketEndpoint}, }) for _, e := range data.Endpoints { @@ -346,26 +362,33 @@ func serverWSSections(data *ServiceData) []*codegen.SectionTemplate { Source: httpTemplates.Read(websocketSendT, websocketUpgradeP), Data: e.ServerWebSocket, FuncMap: map[string]any{ - "upgradeParams": upgradeParams, - "viewedServerBody": viewedServerBody, + "upgradeParams": upgradeParams, + "viewedServerBody": viewedServerBody, + "isClientStreamKind": isClientStreamKind, }, }) } switch e.ServerWebSocket.Kind { case expr.ClientStreamKind, expr.BidirectionalStreamKind: sections = append(sections, &codegen.SectionTemplate{ - Name: "server-websocket-recv", - Source: httpTemplates.Read(websocketRecvT, websocketUpgradeP), - Data: e.ServerWebSocket, - FuncMap: map[string]any{"upgradeParams": upgradeParams}, + Name: "server-websocket-recv", + Source: httpTemplates.Read(websocketRecvT, websocketUpgradeP), + Data: e.ServerWebSocket, + FuncMap: map[string]any{ + "upgradeParams": upgradeParams, + "isClientStreamKind": isClientStreamKind, + }, }) } if e.ServerWebSocket.MustClose { sections = append(sections, &codegen.SectionTemplate{ - Name: "server-websocket-close", - Source: httpTemplates.Read(websocketCloseT), - Data: e.ServerWebSocket, - FuncMap: map[string]any{"upgradeParams": upgradeParams}, + Name: "server-websocket-close", + Source: httpTemplates.Read(websocketCloseT, websocketUpgradeP), + Data: e.ServerWebSocket, + FuncMap: map[string]any{ + "upgradeParams": upgradeParams, + "isClientStreamKind": isClientStreamKind, + }, }) } if e.Method.ViewedResult != nil && e.Method.ViewedResult.ViewName == "" { @@ -383,11 +406,12 @@ func serverWSSections(data *ServiceData) []*codegen.SectionTemplate { // clientStructWSSections return section templates that generate WebSocket // related struct type definitions for the client. func clientStructWSSections(data *ServiceData) []*codegen.SectionTemplate { + configurer := &connConfigurerData{data, data.ClientConnConfigurerDeclaration, data.ClientConnConfigurerInitDeclaration} var sections []*codegen.SectionTemplate sections = append(sections, &codegen.SectionTemplate{ Name: "client-websocket-conn-configurer-struct", Source: httpTemplates.Read(websocketConnConfigurerStructT), - Data: data, + Data: configurer, FuncMap: map[string]any{"isWebSocketEndpoint": IsWebSocketEndpoint}, }) for _, e := range data.Endpoints { @@ -405,21 +429,25 @@ func clientStructWSSections(data *ServiceData) []*codegen.SectionTemplate { // clientWSSections returns section templates that contain client WebSocket // specific code for the given service. func clientWSSections(data *ServiceData) []*codegen.SectionTemplate { + configurer := &connConfigurerData{data, data.ClientConnConfigurerDeclaration, data.ClientConnConfigurerInitDeclaration} var sections []*codegen.SectionTemplate sections = append(sections, &codegen.SectionTemplate{ Name: "client-websocket-conn-configurer-struct-init", Source: httpTemplates.Read(websocketConnConfigurerStructInitT), - Data: data, + Data: configurer, FuncMap: map[string]any{"isWebSocketEndpoint": IsWebSocketEndpoint}, }) for _, e := range data.Endpoints { if e.ClientWebSocket != nil { if e.ClientWebSocket.RecvTypeRef != "" { sections = append(sections, &codegen.SectionTemplate{ - Name: "client-websocket-recv", - Source: httpTemplates.Read(websocketRecvT, websocketUpgradeP), - Data: e.ClientWebSocket, - FuncMap: map[string]any{"upgradeParams": upgradeParams}, + Name: "client-websocket-recv", + Source: httpTemplates.Read(websocketRecvT, websocketUpgradeP), + Data: e.ClientWebSocket, + FuncMap: map[string]any{ + "upgradeParams": upgradeParams, + "isClientStreamKind": isClientStreamKind, + }, }) } switch e.ClientWebSocket.Kind { @@ -429,8 +457,9 @@ func clientWSSections(data *ServiceData) []*codegen.SectionTemplate { Source: httpTemplates.Read(websocketSendT, websocketUpgradeP), Data: e.ClientWebSocket, FuncMap: map[string]any{ - "upgradeParams": upgradeParams, - "viewedServerBody": viewedServerBody, + "upgradeParams": upgradeParams, + "viewedServerBody": viewedServerBody, + "isClientStreamKind": isClientStreamKind, }, }) } @@ -460,6 +489,17 @@ func HasWebSocket(sd *ServiceData) bool { return slices.ContainsFunc(sd.Endpoints, IsWebSocketEndpoint) } +// isClientStreamKind reports whether the client finishes sending before it +// receives the server's single result. +func isClientStreamKind(kind expr.StreamKind) bool { + return kind == expr.ClientStreamKind +} + +// isServerStreamKind reports whether the client only receives stream values. +func isServerStreamKind(kind expr.StreamKind) bool { + return kind == expr.ServerStreamKind +} + // IsWebSocketEndpoint returns true if the endpoint defines a streaming payload // or result. func IsWebSocketEndpoint(ed *EndpointData) bool { diff --git a/http/codegen/websocket_golden_test.go b/http/codegen/websocket_golden_test.go index e6ab235778..d7bb344da7 100644 --- a/http/codegen/websocket_golden_test.go +++ b/http/codegen/websocket_golden_test.go @@ -73,13 +73,13 @@ func TestWebSocketGoldenFiles(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { root := expr.RunDSL(t, c.dsl) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) var files []*codegen.File if c.fileType == "server" { - files = ServerFiles("", services) + files = plan.ServerFiles() } else { - files = ClientFiles("", services) + files = plan.ClientFiles() } // Find the websocket.go file @@ -120,11 +120,11 @@ func TestWebSocketGoldenFiles(t *testing.T) { func TestWebSocketTemplateExercise(t *testing.T) { // Run a comprehensive test that should exercise all templates root := expr.RunDSL(t, comprehensiveWebSocketDSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) // Generate both server and client files - serverFiles := ServerFiles("", services) - clientFiles := ClientFiles("", services) + serverFiles := plan.ServerFiles() + clientFiles := plan.ClientFiles() // Verify WebSocket files were generated var serverWSFile, clientWSFile *codegen.File @@ -388,7 +388,9 @@ func bidirectionalStreamingWithViewsDSL() { Service("TestService", func() { Method("BidirectionalWithViews", func() { StreamingPayload(Request) - StreamingResult(Response) + StreamingResult(Response, func() { + View("minimal") + }) HTTP(func() { GET("/bidirectional/views") }) @@ -511,7 +513,9 @@ func comprehensiveWebSocketDSL() { Method("BidirectionalStreaming", func() { StreamingPayload(UserType) - StreamingResult(UserType) + StreamingResult(UserType, func() { + View("tiny") + }) HTTP(func() { GET("/bidirectional") }) diff --git a/http/codegen/wire_catalog.go b/http/codegen/wire_catalog.go new file mode 100644 index 0000000000..fde3fc5ff3 --- /dev/null +++ b/http/codegen/wire_catalog.go @@ -0,0 +1,1908 @@ +// This file assigns Go names to request and response types in one generated +// HTTP or JSON-RPC package. Each copied type is recorded before names are +// assigned. Its definition, references, and validation function then use the +// same record. +package codegen + +import ( + "cmp" + "fmt" + "reflect" + "slices" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // wireTypeCatalog stores every request or response type written into one Go + // package and the Go name chosen for each type. + wireTypeCatalog struct { + pkg *codegen.GeneratedPackage + scope *codegen.NameScope + records []*wireTypeRecord + transforms []*wireTransformRecord + unionOccurrences []wireUnionOccurrence + unions []*wireUnionRecord + validationRoots []wireValidationRoot + transformHelpers []*wireTransformHelperRecord + transformBindings map[codegen.TransformHelperID]*wireTransformHelperRecord + transformDefinitions map[*codegen.NameDeclaration]*codegen.TransformFunctionData + releasedSuffixes map[*expr.AttributeExpr]string + declared bool + linked bool + bindings map[*expr.AttributeExpr]*wireTypeRecord + unionBindings map[*expr.Union]*wireUnionRecord + } + + // wireTransformHandle identifies one exact request or response conversion + // recorded before generated package names are assigned. + wireTransformHandle struct { + catalog *wireTypeCatalog + record *wireTransformRecord + } + + // wireTransformRecord stores one value conversion and any extra functions it + // needs. + wireTransformRecord struct { + source *expr.AttributeExpr + target *expr.AttributeExpr + prefix string + owner string + layout wireTransformLayout + plan *codegen.TransformPlan + used bool + } + + // wireTransformLayout records which value belongs to the transport package, + // which pointer rules it uses, and whether the other value belongs to the + // generated service package or its views package. + wireTransformLayout struct { + wireSide wireTransformSide + wirePolicy wireTypePolicy + servicePointer bool + servicePackage codegen.ImportSpec + } + + // wireTransformHelperRecord stores one function declaration shared by + // matching conversions in the same generated package. + wireTransformHelperRecord struct { + identity wireTransformHelperIdentity + declaration *codegen.NameDeclaration + prefix string + preferred string + order wireNameOrder + } + + // wireTransformHelperIdentity contains the generated source and target Go + // types plus the nil behavior of one conversion function. + wireTransformHelperIdentity struct { + source wireTransformTypeIdentity + target wireTransformTypeIdentity + required bool + } + + // wireTransformTypeIdentity selects either one HTTP package declaration or + // one service type and records the field layout used by its generated code. + wireTransformTypeIdentity struct { + wire *wireTypeRecord + origin expr.UserType + attribute *expr.AttributeExpr + layout codegen.GoLayoutPolicy + servicePackage codegen.ImportSpec + } + + // wireUnionRecord stores one generated union and the Go names used for its + // type, branches, constants, and functions. + wireUnionRecord struct { + identity wireUnionIdentity + union *expr.Union + declaration *codegen.NameDeclaration + kind *codegen.NameDeclaration + kindDecls []*codegen.NameDeclaration + ctorDecls []*codegen.NameDeclaration + name string + kindName string + kindConsts []string + constructors []string + data *service.UnionTypeData + } + + // wireUnionOccurrence stores one copied union until Goa assigns names to its branches. + wireUnionOccurrence struct { + union *expr.Union + role wireTypeRole + policy wireTypePolicy + api string + } + + // wireValidationRoot stores one HTTP value whose generated code runs + // validation directly instead of calling a named validator. + wireValidationRoot struct { + attribute *expr.AttributeExpr + policy wireTypePolicy + } + + // wireUnionIdentity pairs a union definition with the Go type used by each branch. + wireUnionIdentity struct { + definition codegen.UnionTypeID + declarations []*wireTypeRecord + releasedOrder uint8 + api string + } + + // wireTypeRecord stores one generated type and its optional functions. + wireTypeRecord struct { + identity wireTypeIdentity + declaration *codegen.NameDeclaration + validator *codegen.NameDeclaration + nestedValidator *codegen.NameDeclaration + constructor *codegen.NameDeclaration + needsValidator bool + needsNestedCall bool + needsConstructor bool + name string + ref string + data *TypeData + errorUses []wireErrorUse + releasedNames []string + } + + // wireErrorUse records one service error whose HTTP body uses a generated + // type declaration. + wireErrorUse struct { + service string + method string + name string + } + + // wireTypeIdentity contains a designed type and the rules that change its Go definition. + wireTypeIdentity struct { + api string + sourceID string + resultID string + role wireTypeRole + preferred string + attribute *expr.AttributeExpr + policy wireTypePolicy + } + + // wireTypePolicy records how one copied type represents fields, pointers, + // default values, validation, and result views. + wireTypePolicy struct { + request bool + pointer bool + useDefault bool + validate bool + arrayElementPointer bool + view string + } + + // wireTypeRole says whether an unnamed designed type is used for a request, + // response, field, or stream value. + wireTypeRole uint8 + + // wireTransformSide identifies which value in a conversion is declared in + // the generated HTTP package. + wireTransformSide uint8 + + // wireNameKind identifies the declaration being ordered for a generated + // package name. + wireNameKind uint8 + + // wireAttributePair remembers two attributes already compared so values that + // refer back to themselves do not cause an endless loop. + wireAttributePair struct { + left *expr.AttributeExpr + right *expr.AttributeExpr + } + + // wireNameOrder contains designed values used to choose stable suffixes when + // several declarations ask for the same Go name. + wireNameOrder struct { + kind wireNameKind + unionUse uint8 + api string + source string + target string + role uint8 + preferred string + shape string + view string + request bool + pointer bool + arrayElementPointer bool + defaults bool + required bool + } + + // wireAttributeScope chooses the Go type name for each copied HTTP field. + wireAttributeScope struct { + catalog *wireTypeCatalog + base codegen.Attributor + pkg string + policy wireTypePolicy + viewRoot *wireTypeRecord + exactOccurrence bool + } +) + +const ( + wireRequestBody wireTypeRole = iota + 1 + wireResponseBody + wireAttribute + wireStreamPayload +) + +const ( + wireTransformSource wireTransformSide = iota + 1 + wireTransformTarget +) + +// These values preserve the former alphabetical category order so replacing +// text keys does not change generated names. +const ( + wireNameConstructor wireNameKind = iota + 1 + wireNameNestedValidator + wireNameTransformHelper + wireNameType + wireNameUnion + wireNameUnionConstant + wireNameUnionConstructor + wireNameUnionKind + wireNameValidator +) + +// newWireTypeCatalog creates the type list for one generated Go package. Tests +// may omit the package when they only compare copied attributes. +func newWireTypeCatalog(pkg ...*codegen.GeneratedPackage) *wireTypeCatalog { + catalog := &wireTypeCatalog{ + bindings: make(map[*expr.AttributeExpr]*wireTypeRecord), + unionBindings: make(map[*expr.Union]*wireUnionRecord), + transformBindings: make(map[codegen.TransformHelperID]*wireTransformHelperRecord), + releasedSuffixes: make(map[*expr.AttributeExpr]string), + } + if len(pkg) > 0 { + catalog.pkg = pkg[0] + } + return catalog +} + +// collect records attribute and every named type it contains. +func (c *wireTypeCatalog) collect(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, api ...string) *wireTypeRecord { + return c.collectWithReleasedNames(attribute, role, policy, "", nil, api...) +} + +// collectWithReleasedNames records a response while keeping the public names +// produced before view selection moved into this package. +func (c *wireTypeCatalog) collectWithReleasedNames(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string, releasedNames map[expr.UserType]string, api ...string) *wireTypeRecord { + if c.declared { + panic("cannot collect an HTTP type after its package declarations are submitted") + } + suffix := releasedWireTypeSuffix(attribute, role) + c.releasedSuffixes[attribute] = suffix + root := "" + if len(api) > 0 { + root = api[0] + } + return c.collectRecursive(attribute, role, policy, preferred, suffix, root, true, releasedNames, make(map[expr.UserType]struct{})) +} + +// collectChildren records named types inside attribute without recording its +// top-level named type a second time. +func (c *wireTypeCatalog) collectChildren(attribute *expr.AttributeExpr, policy wireTypePolicy, api ...string) { + c.collectChildrenWithReleasedNames(attribute, policy, nil, api...) +} + +// collectChildrenWithReleasedNames records response fields with their released +// names when selecting a view changed the order of name suffixes. +func (c *wireTypeCatalog) collectChildrenWithReleasedNames(attribute *expr.AttributeExpr, policy wireTypePolicy, releasedNames map[expr.UserType]string, api ...string) { + suffix := c.releasedSuffixes[attribute] + root := "" + if len(api) > 0 { + root = api[0] + } + if userType, ok := attribute.Type.(expr.UserType); ok { + c.collectRecursive(userType.Attribute(), wireAttribute, policy, "", suffix, root, false, releasedNames, make(map[expr.UserType]struct{})) + return + } + c.collectRecursive(attribute, wireAttribute, policy, "", suffix, root, false, releasedNames, make(map[expr.UserType]struct{})) +} + +// Declare requests every type and function name this HTTP package can write. +// The caller invokes it before Goa chooses names so every file writing to the +// same package can resolve conflicts together. +func (c *wireTypeCatalog) Declare() error { + if c.declared { + return nil + } + if c.pkg == nil { + return fmt.Errorf("HTTP type declarations require a generated package") + } + c.planNestedValidators() + for _, record := range c.records { + record.declaration = codegen.NewPreferredName( + codegen.NameType, + record.preferredName(), + codegen.ExportedName, + record.identity.order(wireNameType), + ) + if err := c.pkg.DeclareName(record.declaration); err != nil { + return err + } + if record.needsValidator { + declaration, err := c.pkg.DeclareDependentName( + codegen.NameFunction, + record.declaration, + "Validate", + "", + record.identity.order(wireNameValidator), + ) + if err != nil { + return err + } + record.validator = declaration + if record.needsNestedCall { + declaration, err = c.pkg.DeclareDependentName( + codegen.NameFunction, + record.declaration, + "validate", + "", + record.identity.order(wireNameNestedValidator), + ) + if err != nil { + return err + } + record.nestedValidator = declaration + } + } + if record.needsConstructor { + declaration, err := c.pkg.DeclareDependentName( + codegen.NameFunction, + record.declaration, + "New", + "", + record.identity.order(wireNameConstructor), + ) + if err != nil { + return err + } + record.constructor = declaration + } + } + for _, occurrence := range c.unionOccurrences { + identity := c.unionIdentity(occurrence.union, occurrence.role, occurrence.policy, occurrence.api) + if record := c.findUnion(identity); record != nil { + record.identity.releasedOrder = min(record.identity.releasedOrder, identity.releasedOrder) + if record.identity.api == "" || identity.api != "" && identity.api < record.identity.api { + record.identity.api = identity.api + } + } else { + c.unions = append(c.unions, &wireUnionRecord{identity: identity, union: occurrence.union}) + } + } + for _, union := range c.unions { + union.declaration = codegen.NewPreferredName( + codegen.NameType, + union.union.Name(), + codegen.ExportedName, + union.identity.order(wireNameUnion, union.union.Name(), ""), + ) + if err := c.pkg.DeclareName(union.declaration); err != nil { + return err + } + kind, err := c.pkg.DeclareDependentName( + codegen.NameType, + union.declaration, + "", + "Kind", + union.identity.order(wireNameUnionKind, union.union.Name(), ""), + ) + if err != nil { + return err + } + union.kind = kind + union.kindDecls = make([]*codegen.NameDeclaration, len(union.union.Values)) + union.ctorDecls = make([]*codegen.NameDeclaration, len(union.union.Values)) + for index, branch := range union.union.Values { + kindDeclaration, err := c.pkg.DeclareDependentName( + codegen.NameConstant, + union.kind, + "", + codegen.Goify(branch.Name, true), + union.identity.order(wireNameUnionConstant, union.union.Name(), branch.Name), + ) + if err != nil { + return err + } + constructor, err := c.pkg.DeclareDependentName( + codegen.NameFunction, + union.declaration, + "New", + codegen.Goify(branch.Name, true), + union.identity.order(wireNameUnionConstructor, union.union.Name(), branch.Name), + ) + if err != nil { + return err + } + union.kindDecls[index] = kindDeclaration + union.ctorDecls[index] = constructor + } + } + for _, transform := range c.transforms { + for _, helper := range transform.plan.Helpers() { + identity, err := c.transformHelperIdentity(transform, helper) + if err != nil { + return err + } + preferred, err := c.transformHelperPreferredName(transform.prefix, identity) + if err != nil { + return err + } + order := wireNameOrder{ + kind: wireNameTransformHelper, + source: identity.source.orderKey(), + target: identity.target.orderKey(), + preferred: preferred, + required: identity.required, + } + record := c.findTransformHelper(identity) + if record == nil { + record = &wireTransformHelperRecord{ + identity: identity, + prefix: transform.prefix, + preferred: preferred, + order: order, + } + c.transformHelpers = append(c.transformHelpers, record) + } else if order.ComparePackageName(record.order) < 0 { + record.prefix = transform.prefix + record.preferred = preferred + record.order = order + } + c.transformBindings[helper.ID] = record + } + } + for _, helper := range c.transformHelpers { + declaration, err := c.declareTransformHelper(helper) + if err != nil { + return err + } + helper.declaration = declaration + } + for _, transform := range c.transforms { + for _, planned := range transform.plan.Helpers() { + helper := c.transformBindings[planned.ID] + if helper == nil { + return fmt.Errorf("HTTP conversion function declaration was not recorded") + } + if err := transform.plan.BindHelperDeclaration(planned.ID, helper.declaration); err != nil { + return err + } + } + } + c.declared = true + return nil +} + +// collectTransform records one request or response conversion before Goa +// chooses the names of any extra conversion functions. The returned handle +// selects this record when the caller later writes the conversion. +func (c *wireTypeCatalog) collectTransform(source, target *expr.AttributeExpr, prefix, owner string, layout wireTransformLayout) wireTransformHandle { + if c.declared { + panic("cannot collect an HTTP conversion after package declarations are submitted") + } + source = expr.DupAtt(source) + target = expr.DupAtt(target) + plan, err := codegen.NewTransformPlan(source, target, "", nil) + if err != nil { + panic(err) + } + record := &wireTransformRecord{ + source: source, + target: target, + prefix: prefix, + owner: owner, + layout: layout, + plan: plan, + } + c.transforms = append(c.transforms, record) + return wireTransformHandle{catalog: c, record: record} +} + +// transformHelperIdentity resolves the exact generated declarations and field +// layouts used by one planned conversion function. +func (c *wireTypeCatalog) transformHelperIdentity(transform *wireTransformRecord, helper codegen.TransformHelper) (wireTransformHelperIdentity, error) { + sourceWire := transform.layout.wireSide == wireTransformSource + targetWire := transform.layout.wireSide == wireTransformTarget + source, err := c.transformTypeIdentity(helper.Source, sourceWire, transform.layout.wirePolicy, transform.layout.servicePointer, transform.layout.servicePackage) + if err != nil { + return wireTransformHelperIdentity{}, err + } + target, err := c.transformTypeIdentity(helper.Target, targetWire, transform.layout.wirePolicy, transform.layout.servicePointer, transform.layout.servicePackage) + if err != nil { + return wireTransformHelperIdentity{}, err + } + return wireTransformHelperIdentity{ + source: source, + target: target, + required: helper.Required, + }, nil +} + +// transformTypeIdentity returns the declaration and field rules that determine +// a generated conversion function's parameter or result type. +func (c *wireTypeCatalog) transformTypeIdentity(attribute *expr.AttributeExpr, wire bool, policy wireTypePolicy, servicePointer bool, servicePackage codegen.ImportSpec) (wireTransformTypeIdentity, error) { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return wireTransformTypeIdentity{}, fmt.Errorf("HTTP conversion function type %q is not named", attribute.Type.Name()) + } + if !wire { + if location := codegen.UserTypeLocation(userType); location != nil { + servicePackage = codegen.ImportSpec{Name: location.PackageName(), Path: location.RelImportPath} + } + if servicePackage.Name == "" || servicePackage.Path == "" { + return wireTransformTypeIdentity{}, fmt.Errorf("HTTP conversion function type %q has no service package", userType.Name()) + } + return wireTransformTypeIdentity{ + origin: userType.Origin(), + attribute: attribute, + servicePackage: servicePackage, + layout: codegen.GoLayoutPolicy{ + Pointer: servicePointer, + UseDefault: true, + SumType: true, + }, + }, nil + } + policy.view = "" + preferred := wireTypePreferredName(userType, policy) + record := c.find(newWireTypeIdentity(attribute, wireAttribute, policy, preferred)) + if record == nil { + return wireTransformTypeIdentity{}, fmt.Errorf("HTTP conversion function type %q was not recorded", preferred) + } + return wireTransformTypeIdentity{ + wire: record, + attribute: attribute, + layout: codegen.GoLayoutPolicy{ + Pointer: policy.pointer, + UseDefault: policy.useDefault, + UnionPointer: true, + ArrayElementPointer: policy.arrayElementPointer, + SumType: true, + }, + }, nil +} + +// transformHelperPreferredName describes both generated types converted by +// one helper before their package selects final declaration names. +func (c *wireTypeCatalog) transformHelperPreferredName(prefix string, identity wireTransformHelperIdentity) (string, error) { + source, err := c.transformTypeRoleName(identity.source) + if err != nil { + return "", err + } + target, err := c.transformTypeRoleName(identity.target) + if err != nil { + return "", err + } + return prefix + codegen.Goify(source, true) + "To" + codegen.Goify(target, true) + identity.behaviorSuffix(), nil +} + +// transformTypeRoleName returns the package and type role used in a helper +// name. Wire values use their planned request or response declaration. +func (c *wireTypeCatalog) transformTypeRoleName(identity wireTransformTypeIdentity) (string, error) { + if identity.wire != nil { + return identity.wire.preferredName(), nil + } + userType, ok := identity.attribute.Type.(expr.UserType) + if !ok { + return "", fmt.Errorf("HTTP conversion function type %q is not named", identity.attribute.Type.Name()) + } + typeName := codegen.Goify(wireTypeDeclaredName(userType), true) + return identity.servicePackage.Name + typeName, nil +} + +// declareTransformHelper makes the function name depend on the wire type it +// converts. If that type receives a suffix, the helper receives it too. +func (c *wireTypeCatalog) declareTransformHelper(helper *wireTransformHelperRecord) (*codegen.NameDeclaration, error) { + sourceWire := helper.identity.source.wire + targetWire := helper.identity.target.wire + if (sourceWire == nil) == (targetWire == nil) { + return nil, fmt.Errorf("HTTP conversion function must convert between one wire type and one service type") + } + if sourceWire != nil { + target, err := c.transformTypeRoleName(helper.identity.target) + if err != nil { + return nil, err + } + return c.pkg.DeclareDependentName( + codegen.NameFunction, + sourceWire.declaration, + helper.prefix, + "To"+codegen.Goify(target, true)+helper.identity.behaviorSuffix(), + helper.order, + ) + } + source, err := c.transformTypeRoleName(helper.identity.source) + if err != nil { + return nil, err + } + return c.pkg.DeclareDependentName( + codegen.NameFunction, + targetWire.declaration, + helper.prefix+codegen.Goify(source, true)+"To", + helper.identity.behaviorSuffix(), + helper.order, + ) +} + +// behaviorSuffix distinguishes a conversion that preserves a missing source +// value from one whose caller guarantees that the source is present. +func (i wireTransformHelperIdentity) behaviorSuffix() string { + if !i.required { + return "Optional" + } + return "" +} + +// findTransformHelper returns the declaration for an equivalent conversion. +func (c *wireTypeCatalog) findTransformHelper(identity wireTransformHelperIdentity) *wireTransformHelperRecord { + for _, record := range c.transformHelpers { + if wireTransformHelperIdentitiesEqual(record.identity, identity) { + return record + } + } + return nil +} + +// wireTransformHelperIdentitiesEqual reports whether two functions have the +// same generated parameter, result, field layout, and nil behavior. +func wireTransformHelperIdentitiesEqual(left, right wireTransformHelperIdentity) bool { + return left.required == right.required && + wireTransformTypeIdentitiesEqual(left.source, right.source) && + wireTransformTypeIdentitiesEqual(left.target, right.target) +} + +// wireTransformTypeIdentitiesEqual compares exact generated declarations and +// the concrete fields written inside service types. +func wireTransformTypeIdentitiesEqual(left, right wireTransformTypeIdentity) bool { + if left.wire != right.wire || left.origin != right.origin || left.layout != right.layout || left.servicePackage != right.servicePackage { + return false + } + if left.wire != nil { + return true + } + leftType := left.attribute.Type.(expr.UserType) + rightType := right.attribute.Type.(expr.UserType) + return wireAttributesEqual(leftType.Attribute(), rightType.Attribute(), make(map[wireAttributePair]struct{})) +} + +// orderKey describes every generated fact that changes a conversion +// function's parameter or result type. +func (i wireTransformTypeIdentity) orderKey() string { + if i.wire != nil { + order := i.wire.identity.order(wireNameType) + return fmt.Sprintf( + "wire:%q:%d:%q:%q:%q:%t:%t:%t:%t", + order.source, + order.role, + order.preferred, + order.shape, + order.view, + order.request, + order.pointer, + order.arrayElementPointer, + order.defaults, + ) + } + return fmt.Sprintf( + "service:%q:%q:%q:%q:%t:%t:%t:%t:%t", + i.servicePackage.Path, + i.servicePackage.Name, + wireTypeDeclaredName(i.origin), + expr.Hash(i.attribute.Type, false, false, false), + i.layout.Pointer, + i.layout.IgnoreRequired, + i.layout.UseDefault, + i.layout.UnionPointer, + i.layout.ArrayElementPointer, + ) +} + +// renderTransform writes the conversion selected when wire types were +// collected. The handle prevents two structurally identical conversions from +// being exchanged when callers render them in a different order. +func (c *wireTypeCatalog) renderTransform( + handle wireTransformHandle, + wireAttribute *expr.AttributeExpr, + sourceVar, targetVar string, + sourceContext, targetContext *codegen.AttributeContext, +) (string, []*codegen.TransformFunctionData, error) { + if handle.catalog != c || handle.record == nil { + return "", nil, fmt.Errorf("HTTP conversion handle belongs to a different generated package") + } + transform := handle.record + if transform.used { + return "", nil, fmt.Errorf("HTTP %s conversion for %s was already rendered", transform.prefix, transform.owner) + } + if err := transform.plan.BindContexts(sourceContext, targetContext); err != nil { + return "", nil, err + } + if transform.layout.wireSide == wireTransformSource { + c.bindTransformOccurrence(transform.source, wireAttribute, sourceContext) + } else { + c.bindTransformOccurrence(transform.target, wireAttribute, targetContext) + } + for _, helper := range transform.plan.Helpers() { + c.bindTransformHelper(helper.Source, sourceContext) + c.bindTransformHelper(helper.Target, targetContext) + } + code, helpers, err := transform.plan.Render(sourceVar, targetVar, true) + if err != nil { + return "", nil, err + } + if err := c.retainTransformDefinitions(helpers); err != nil { + return "", nil, err + } + transform.used = true + return code, helpers, nil +} + +// checkTransformUsed rejects a planned conversion that was never written. A +// generated package may contain records from several transport plans, so the +// caller checks only handles owned by the plan currently linking. +func (c *wireTypeCatalog) checkTransformUsed(handle wireTransformHandle) error { + if handle.record == nil { + return nil + } + if handle.catalog != c { + return fmt.Errorf("HTTP conversion handle belongs to a different generated package") + } + if !handle.record.used { + return fmt.Errorf("HTTP %s conversion for %s was planned but not rendered", handle.record.prefix, handle.record.owner) + } + return nil +} + +// retainTransformDefinitions verifies that every independently planned use of +// one package function has the same parameter type, result type, and body. +func (c *wireTypeCatalog) retainTransformDefinitions(helpers []*codegen.TransformFunctionData) error { + if c.transformDefinitions == nil { + c.transformDefinitions = make(map[*codegen.NameDeclaration]*codegen.TransformFunctionData) + } + pending := make(map[*codegen.NameDeclaration]*codegen.TransformFunctionData) + for _, helper := range helpers { + previous := c.transformDefinitions[helper.Declaration] + if previous == nil { + previous = pending[helper.Declaration] + } + if previous != nil && !wireTransformDefinitionsEqual(previous, helper) { + return fmt.Errorf("HTTP transform helper declaration %q has different definitions", helper.Declaration.Name()) + } + pending[helper.Declaration] = helper + } + for declaration, helper := range pending { + c.transformDefinitions[declaration] = helper + } + return nil +} + +// wireTransformDefinitionsEqual reports whether two planned conversions emit +// the same package-level function. +func wireTransformDefinitionsEqual(left, right *codegen.TransformFunctionData) bool { + return left.ParamTypeRef == right.ParamTypeRef && + left.ResultTypeRef == right.ResultTypeRef && + left.Code == right.Code +} + +// bindTransformHelper gives a nested copied field the same Go type name used by +// its generated conversion function. Service values already receive names from +// the service generator. +func (c *wireTypeCatalog) bindTransformHelper(attribute *expr.AttributeExpr, context *codegen.AttributeContext) { + scope, ok := context.Scope.(*wireAttributeScope) + if !ok || scope.catalog != c { + return + } + policy := scope.policy + policy.view = "" + c.applyNamesRecursive(attribute, wireAttribute, policy, make(map[expr.UserType]struct{})) +} + +// bindTransformOccurrence records the Go type used by one copied conversion +// value and each named field inside it. +func (c *wireTypeCatalog) bindTransformOccurrence(planned, rendered *expr.AttributeExpr, context *codegen.AttributeContext) { + scope, ok := context.Scope.(*wireAttributeScope) + if !ok || scope.catalog != c { + return + } + record := c.bindings[rendered] + if record == nil { + c.applyNamesRecursive(planned, wireAttribute, scope.policy, make(map[expr.UserType]struct{})) + return + } + c.bindOccurrence(planned, record) + c.applyNamesRecursive(planned, record.identity.role, record.identity.policy, make(map[expr.UserType]struct{})) +} + +// Link reads the assigned package names and builds the type definitions, +// references, unions, and validation functions written to files. +func (c *wireTypeCatalog) Link() { + if c.linked { + return + } + if !c.declared { + panic("cannot link HTTP types before declaring their package names") + } + c.scope = c.pkg.Scope() + for _, record := range c.records { + record.name = record.declaration.Name() + record.ref = wireTypeRef(record.name, record.identity.attribute.Type) + } + for _, union := range c.unions { + union.name = union.declaration.Name() + union.kindName = union.kind.Name() + union.kindConsts = make([]string, len(union.kindDecls)) + union.constructors = make([]string, len(union.ctorDecls)) + for index := range union.kindDecls { + union.kindConsts[index] = union.kindDecls[index].Name() + union.constructors[index] = union.ctorDecls[index].Name() + } + c.applyUnionRecord(union.union, union) + } + for _, union := range c.unions { + union.data = buildHTTPUnionTypeData(union.union, c.occurrenceResolver(c.scope), union) + } + c.linked = true +} + +// wireTypeRef adds a pointer when the generated Go type requires one. +func wireTypeRef(name string, dataType expr.DataType) string { + if _, inline := dataType.(*expr.Object); inline { + return name + } + if expr.IsObject(dataType) || expr.IsUnion(dataType) { + return "*" + name + } + return name +} + +// lookup returns the chosen Go names for an equivalent copied type. It panics +// when the type was not recorded before names were assigned. +func (c *wireTypeCatalog) lookup(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string) *wireTypeRecord { + if !c.linked { + panic("cannot resolve an HTTP type before its generated package freezes") + } + identity := newWireTypeIdentity(attribute, role, policy, preferred) + record := c.find(identity) + if record != nil { + c.bindOccurrence(attribute, record) + return record + } + panic(fmt.Sprintf("HTTP type %q was not submitted before package names were assigned", preferred)) +} + +// lookupUser returns the generated name information for a named design type. +// It returns nil for inline and primitive values because they define no named +// type at the top level. +func (c *wireTypeCatalog) lookupUser(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy) *wireTypeRecord { + if attribute.Type == expr.Empty { + return nil + } + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return nil + } + return c.lookup(attribute, role, policy, wireTypePreferredName(userType, policy)) +} + +// applyNames associates every copied nested attribute with the Go type name +// used by its definition and conversions. +func (c *wireTypeCatalog) applyNames(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy) { + c.applyNamesRecursive(attribute, role, policy, make(map[expr.UserType]struct{})) +} + +// applyNamesRecursive follows each named field once, including fields that +// refer back to an outer type. +func (c *wireTypeCatalog) applyNamesRecursive(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, seen map[expr.UserType]struct{}) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + c.lookupUser(attribute, role, policy) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + nestedPolicy := policy + nestedPolicy.view = "" + c.applyNamesRecursive(userType.Attribute(), wireAttribute, nestedPolicy, seen) + delete(seen, origin) + return + } + nestedPolicy := policy + nestedPolicy.view = "" + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, named := range *actual { + c.applyNamesRecursive(named.Attribute, wireAttribute, nestedPolicy, seen) + } + case *expr.Array: + c.applyNamesRecursive(actual.ElemType, wireAttribute, nestedPolicy, seen) + case *expr.Map: + c.applyNamesRecursive(actual.KeyType, wireAttribute, nestedPolicy, seen) + c.applyNamesRecursive(actual.ElemType, wireAttribute, nestedPolicy, seen) + case *expr.Union: + identity := c.unionIdentity(actual, role, policy) + record := c.findUnion(identity) + if record == nil { + panic(fmt.Sprintf("HTTP union %q was not submitted before package names were assigned", actual.Name())) + } + c.applyUnionRecord(actual, record) + } +} + +// unionTypes returns the generated union definitions in Go name order. +func (c *wireTypeCatalog) unionTypes() []*service.UnionTypeData { + unions := make([]*service.UnionTypeData, len(c.unions)) + for index, record := range c.unions { + unions[index] = record.data + } + slices.SortFunc(unions, func(left, right *service.UnionTypeData) int { return strings.Compare(left.Name, right.Name) }) + return unions +} + +// bind associates the data used to write a type with its chosen Go name. When +// several equivalent copies need validation, it stores their shared validator. +func (c *wireTypeCatalog) bind(record *wireTypeRecord, data *TypeData) *TypeData { + data.declaration = record + data.Declaration = record.declaration + data.VarName = record.declaration.Name() + data.ValidatorDeclaration = record.validator + data.NestedValidatorDeclaration = record.nestedValidator + if record.validator != nil { + data.ValidatorName = record.validator.Name() + } + if record.nestedValidator != nil { + data.NestedValidatorName = record.nestedValidator.Name() + } + if data.Init != nil { + data.Init.Declaration = record.constructor + data.Init.Name = record.constructor.Name() + } + if description := record.errorDescription(); description != "" { + data.Description = description + } + if record.data == nil { + if data.Def == "" && data.ValidateDef == "" { + return data + } + declaration := *data + declaration.Init = nil + record.data = &declaration + return data + } + if data.Def != "" { + if record.data.Def == "" { + record.data.Def = data.Def + } else if record.data.Def != data.Def { + panic(fmt.Sprintf("HTTP type %q produced conflicting declarations", record.name)) + } + } + if data.ValidateDef != "" { + if record.data.ValidateDef == "" { + record.data.ValidateDef = data.ValidateDef + record.data.ValidateRef = data.ValidateRef + record.data.ValidatorName = data.ValidatorName + } else if record.data.ValidateDef != data.ValidateDef || record.data.ValidateRef != data.ValidateRef { + panic(fmt.Sprintf("HTTP type %q produced conflicting validators", record.name)) + } + } + if data.NestedValidateDef != "" { + if record.data.NestedValidateDef == "" { + record.data.NestedValidateDef = data.NestedValidateDef + record.data.NestedValidatorName = data.NestedValidatorName + } else if record.data.NestedValidateDef != data.NestedValidateDef { + panic(fmt.Sprintf("HTTP type %q produced conflicting nested validators", record.name)) + } + } + return data +} + +// addErrorUse records one error body role and keeps all roles in a stable +// order before generated source is written. +func (r *wireTypeRecord) addErrorUse(use wireErrorUse) { + for _, existing := range r.errorUses { + if existing == use { + return + } + } + r.errorUses = append(r.errorUses, use) + slices.SortFunc(r.errorUses, func(left, right wireErrorUse) int { + for _, compared := range []int{ + cmp.Compare(left.service, right.service), + cmp.Compare(left.method, right.method), + cmp.Compare(left.name, right.name), + } { + if compared != 0 { + return compared + } + } + return 0 + }) +} + +// errorDescription describes every designed error that uses the generated +// type. Two errors on one endpoint remain short enough for one sentence. +func (r *wireTypeRecord) errorDescription() string { + if len(r.errorUses) == 0 { + return "" + } + first := r.errorUses[0] + if len(r.errorUses) == 1 { + return fmt.Sprintf( + "%s is the type of the %q service %q endpoint HTTP response body for the %q error.", + r.name, + first.service, + first.method, + first.name, + ) + } + if len(r.errorUses) == 2 && first.service == r.errorUses[1].service && first.method == r.errorUses[1].method { + return fmt.Sprintf( + "%s is the type of the %q service %q endpoint HTTP response body for the %q and %q errors.", + r.name, + first.service, + first.method, + first.name, + r.errorUses[1].name, + ) + } + var description strings.Builder + fmt.Fprintf(&description, "%s is the HTTP response body type for these service errors:", r.name) + for _, use := range r.errorUses { + fmt.Fprintf( + &description, + "\n- %q service %q endpoint: %q error", + use.service, + use.method, + use.name, + ) + } + return description.String() +} + +// collectRecursive records named types and stops when a type refers back to one +// it is already reading. Released names are kept separately from current type +// identity so several old declarations can share one current declaration. +func (c *wireTypeCatalog) collectRecursive(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred, releasedSuffix, api string, root bool, releasedNames map[expr.UserType]string, seen map[expr.UserType]struct{}) *wireTypeRecord { + if attribute.Type == expr.Empty { + return nil + } + var record *wireTypeRecord + if userType, ok := attribute.Type.(expr.UserType); ok { + preferred = wireTypePreferredName(userType, policy) + identity := newWireTypeIdentity(attribute, role, policy, preferred) + identity.api = api + record = c.findOrAppend(identity) + released := preferred + if name := releasedNames[userType]; name != "" { + released = name + } else if !root { + released += releasedSuffix + } + record.addReleasedName(released) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return record + } + seen[origin] = struct{}{} + nestedPolicy := policy + nestedPolicy.view = "" + c.collectRecursive(userType.Attribute(), wireAttribute, nestedPolicy, "", releasedSuffix, api, false, releasedNames, seen) + delete(seen, origin) + return record + } + if preferred != "" { + identity := newWireTypeIdentity(attribute, role, policy, preferred) + identity.api = api + record = c.findOrAppend(identity) + record.addReleasedName(preferred) + } + switch actual := attribute.Type.(type) { + case *expr.Object: + nestedPolicy := policy + nestedPolicy.view = "" + for _, named := range sortedWireAttributes(*actual) { + c.collectRecursive(named.Attribute, wireAttribute, nestedPolicy, "", releasedSuffix, api, false, releasedNames, seen) + } + case *expr.Array: + nestedPolicy := policy + nestedPolicy.view = "" + c.collectRecursive(actual.ElemType, wireAttribute, nestedPolicy, "", releasedSuffix, api, false, releasedNames, seen) + case *expr.Map: + nestedPolicy := policy + nestedPolicy.view = "" + c.collectRecursive(actual.KeyType, wireAttribute, nestedPolicy, "", releasedSuffix, api, false, releasedNames, seen) + c.collectRecursive(actual.ElemType, wireAttribute, nestedPolicy, "", releasedSuffix, api, false, releasedNames, seen) + case *expr.Union: + nestedPolicy := policy + nestedPolicy.view = "" + union := expr.Dup(actual).(*expr.Union) + c.unionOccurrences = append(c.unionOccurrences, wireUnionOccurrence{union: union, role: role, policy: policy, api: api}) + for _, named := range actual.Values { + c.collectRecursive(named.Attribute, wireAttribute, nestedPolicy, "", releasedSuffix, api, false, releasedNames, seen) + } + } + return record +} + +// addReleasedName records one spelling used before HTTP types were retained. +func (r *wireTypeRecord) addReleasedName(name string) { + if name == "" || slices.Contains(r.releasedNames, name) { + return + } + r.releasedNames = append(r.releasedNames, name) + slices.Sort(r.releasedNames) +} + +// preferredName keeps a released spelling only when it still names exactly one +// retained declaration. Shared declarations use their current designed name. +func (r *wireTypeRecord) preferredName() string { + if len(r.releasedNames) == 1 { + return r.releasedNames[0] + } + return r.identity.preferred +} + +// findOrAppend reuses a record with the same generated type definition or adds +// a new record. +func (c *wireTypeCatalog) findOrAppend(identity wireTypeIdentity) *wireTypeRecord { + if record := c.find(identity); record != nil { + record.needsValidator = record.needsValidator || identity.policy.validate + return record + } + record := &wireTypeRecord{ + identity: identity, + needsValidator: identity.policy.validate, + } + c.records = append(c.records, record) + return record +} + +// addValidationRoot records an inline HTTP value whose generated decoder or +// constructor runs validation. +func (c *wireTypeCatalog) addValidationRoot(attribute *expr.AttributeExpr, policy wireTypePolicy) { + c.validationRoots = append(c.validationRoots, wireValidationRoot{ + attribute: expr.DupAtt(attribute), + policy: policy, + }) +} + +// planNestedValidators marks the named validators called by generated public +// validators and inline validation code. +func (c *wireTypeCatalog) planNestedValidators() { + for _, record := range c.records { + record.needsNestedCall = false + } + for _, record := range c.records { + if record.needsValidator { + c.markNestedValidatorCalls(record.identity.attribute, record.identity.policy) + } + } + for _, root := range c.validationRoots { + c.markNestedValidatorCalls(root.attribute, root.policy) + } +} + +// markNestedValidatorCalls follows inline validation until it reaches a named +// type. A named type gets one private helper because the caller supplies its +// complete error path. +func (c *wireTypeCatalog) markNestedValidatorCalls(attribute *expr.AttributeExpr, policy wireTypePolicy) { + if userType, ok := attribute.Type.(expr.UserType); ok && !expr.IsAlias(userType) { + attribute = userType.Attribute() + } + policy.view = "" + c.markInlineValidationCalls(attribute, policy, policy.pointer) +} + +// markInlineValidationCalls records named calls inside one generated +// validation body. Anonymous values and aliases remain inside their caller. +func (c *wireTypeCatalog) markInlineValidationCalls(attribute *expr.AttributeExpr, policy wireTypePolicy, pointer bool) { + if userType, ok := attribute.Type.(expr.UserType); ok { + if expr.IsAlias(userType) { + c.markInlineValidationCalls(userType.Attribute(), policy, pointer) + return + } + layout := codegen.GoLayoutPolicy{ + Pointer: pointer, + UseDefault: policy.useDefault, + UnionPointer: true, + ArrayElementPointer: policy.arrayElementPointer, + SumType: true, + } + if !codegen.NeedsValidation(userType.Attribute(), layout) { + return + } + preferred := wireTypePreferredName(userType, policy) + record := c.find(newWireTypeIdentity(attribute, wireAttribute, policy, preferred)) + if record == nil { + panic(fmt.Sprintf("HTTP nested validator for %q was not collected", preferred)) + } + if !record.needsValidator { + panic(fmt.Sprintf("HTTP nested validator for %q has no public validator", record.name)) + } + record.needsNestedCall = true + return + } + + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, field := range *actual { + c.markInlineValidationCalls(field.Attribute, policy, pointer) + } + case *expr.Array: + c.markInlineValidationCalls(actual.ElemType, policy, pointer) + case *expr.Map: + c.markInlineValidationCalls(actual.KeyType, policy, false) + c.markInlineValidationCalls(actual.ElemType, policy, false) + case *expr.Union: + for _, branch := range actual.Values { + branchPointer := pointer && expr.IsObject(branch.Attribute.Type) + c.markInlineValidationCalls(branch.Attribute, policy, branchPointer) + } + } +} + +// find returns the record for the same generated type definition. +func (c *wireTypeCatalog) find(identity wireTypeIdentity) *wireTypeRecord { + for _, record := range c.records { + if wireTypeIdentitiesEqual(record.identity, identity) { + return record + } + } + return nil +} + +// unionIdentity returns the Go type used by every named branch of +// union without changing union. +func (c *wireTypeCatalog) unionIdentity(union *expr.Union, role wireTypeRole, policy wireTypePolicy, api ...string) wireUnionIdentity { + identity := wireUnionIdentity{ + definition: codegen.NewUnionTypeID(union), + releasedOrder: releasedUnionOrder(policy), + } + if len(api) > 0 { + identity.api = api[0] + } + attribute := &expr.AttributeExpr{Type: union} + c.collectUnionDeclarations(attribute, role, policy, &identity.declarations, make(map[expr.UserType]struct{})) + return identity +} + +// releasedUnionOrder keeps the request union name when request and response +// copies of the same designed union need different Go declarations. +func releasedUnionOrder(policy wireTypePolicy) uint8 { + if policy.request { + return 1 + } + return 2 +} + +// collectUnionDeclarations records generated branch types in branch order. +func (c *wireTypeCatalog) collectUnionDeclarations(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, declarations *[]*wireTypeRecord, seen map[expr.UserType]struct{}) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + preferred := wireTypePreferredName(userType, policy) + record := c.find(newWireTypeIdentity(attribute, role, policy, preferred)) + if record == nil { + panic(fmt.Sprintf("HTTP union branch type %q was not submitted before package names were assigned", preferred)) + } + *declarations = append(*declarations, record) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + nestedPolicy := policy + nestedPolicy.view = "" + c.collectUnionDeclarations(userType.Attribute(), wireAttribute, nestedPolicy, declarations, seen) + delete(seen, origin) + return + } + nestedPolicy := policy + nestedPolicy.view = "" + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, named := range *actual { + c.collectUnionDeclarations(named.Attribute, wireAttribute, nestedPolicy, declarations, seen) + } + case *expr.Array: + c.collectUnionDeclarations(actual.ElemType, wireAttribute, nestedPolicy, declarations, seen) + case *expr.Map: + c.collectUnionDeclarations(actual.KeyType, wireAttribute, nestedPolicy, declarations, seen) + c.collectUnionDeclarations(actual.ElemType, wireAttribute, nestedPolicy, declarations, seen) + case *expr.Union: + for _, named := range actual.Values { + c.collectUnionDeclarations(named.Attribute, wireAttribute, nestedPolicy, declarations, seen) + } + } +} + +// applyUnionRecord gives one copied union the exact branch type names stored in +// record. +func (c *wireTypeCatalog) applyUnionRecord(union *expr.Union, record *wireUnionRecord) { + c.unionBindings[union] = record + index := 0 + seen := make(map[expr.UserType]struct{}) + for _, branch := range union.Values { + c.applyResolvedDeclarations(branch.Attribute, record.identity.declarations, &index, seen) + } + if index != len(record.identity.declarations) { + panic(fmt.Sprintf("HTTP union %q did not use every submitted branch name", record.name)) + } +} + +// applyResolvedDeclarations gives each named branch its previously chosen Go +// type in the same order those types were recorded. +func (c *wireTypeCatalog) applyResolvedDeclarations(attribute *expr.AttributeExpr, declarations []*wireTypeRecord, index *int, seen map[expr.UserType]struct{}) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + if *index >= len(declarations) { + panic(fmt.Sprintf("HTTP union branch %q has no submitted Go type name", wireTypeDeclaredName(userType))) + } + record := declarations[*index] + (*index)++ + c.bindOccurrence(attribute, record) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + c.applyResolvedDeclarations(userType.Attribute(), declarations, index, seen) + delete(seen, origin) + return + } + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, named := range *actual { + c.applyResolvedDeclarations(named.Attribute, declarations, index, seen) + } + case *expr.Array: + c.applyResolvedDeclarations(actual.ElemType, declarations, index, seen) + case *expr.Map: + c.applyResolvedDeclarations(actual.KeyType, declarations, index, seen) + c.applyResolvedDeclarations(actual.ElemType, declarations, index, seen) + case *expr.Union: + definition := codegen.NewUnionTypeID(actual) + start := *index + for _, branch := range actual.Values { + c.applyResolvedDeclarations(branch.Attribute, declarations, index, seen) + } + identity := wireUnionIdentity{definition: definition, declarations: declarations[start:*index]} + record := c.findUnion(identity) + if record == nil { + panic(fmt.Sprintf("HTTP nested union %q has no submitted Go type name", actual.Name())) + } + c.unionBindings[actual] = record + } +} + +// resolver returns the chosen HTTP type names. The supplied name list is used +// only to keep local variable names unique. +func (c *wireTypeCatalog) resolver(scope *codegen.NameScope, policy wireTypePolicy) codegen.Attributor { + return &wireAttributeScope{catalog: c, base: codegen.NewAttributeScope(scope), policy: policy} +} + +// occurrenceResolver uses only the exact type records attached to one copied +// expression. Union declarations use it after their branch records are fixed. +func (c *wireTypeCatalog) occurrenceResolver(scope *codegen.NameScope) codegen.Attributor { + return &wireAttributeScope{ + catalog: c, + base: codegen.NewAttributeScope(scope), + exactOccurrence: true, + } +} + +// rootResolver applies a selected result view only to root. Nested fields use +// their normal type definitions without that view. +func (c *wireTypeCatalog) rootResolver(scope *codegen.NameScope, policy wireTypePolicy, root *wireTypeRecord) codegen.Attributor { + return &wireAttributeScope{catalog: c, base: codegen.NewAttributeScope(scope), policy: policy, viewRoot: root} +} + +// bindOccurrence records the Go type used by one copied named value and its fields. +func (c *wireTypeCatalog) bindOccurrence(attribute *expr.AttributeExpr, record *wireTypeRecord) { + c.bindings[attribute] = record + if userType, ok := attribute.Type.(expr.UserType); ok { + c.bindings[userType.Attribute()] = record + } +} + +// applyReleasedNames gives a copied composite body the public nested type +// names used to build its released constructor name. +func (c *wireTypeCatalog) applyReleasedNames(attribute *expr.AttributeExpr, policy wireTypePolicy, seen map[expr.UserType]struct{}) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + preferred := wireTypePreferredName(userType, policy) + record := c.find(newWireTypeIdentity(attribute, wireAttribute, policy, preferred)) + if record == nil { + panic(fmt.Sprintf("HTTP type %q was not submitted before its constructor name was built", preferred)) + } + userType.Attribute().AddMeta("struct:type:name", record.preferredName()) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + nestedPolicy := policy + nestedPolicy.view = "" + c.applyReleasedNames(userType.Attribute(), nestedPolicy, seen) + delete(seen, origin) + return + } + nestedPolicy := policy + nestedPolicy.view = "" + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, named := range *actual { + c.applyReleasedNames(named.Attribute, nestedPolicy, seen) + } + case *expr.Array: + c.applyReleasedNames(actual.ElemType, nestedPolicy, seen) + case *expr.Map: + c.applyReleasedNames(actual.KeyType, nestedPolicy, seen) + c.applyReleasedNames(actual.ElemType, nestedPolicy, seen) + case *expr.Union: + for _, named := range actual.Values { + c.applyReleasedNames(named.Attribute, nestedPolicy, seen) + } + } +} + +// releasedCompositeName returns the old public name for an array or map after +// applying its nested public type names. +func (c *wireTypeCatalog) releasedCompositeName(body *expr.AttributeExpr, policy wireTypePolicy) string { + body = expr.DupAtt(body) + c.applyReleasedNames(body, policy, make(map[expr.UserType]struct{})) + name := codegen.NewAttributeScope(codegen.NewNameScope()).Name(body, "", policy.pointer, policy.useDefault) + return codegen.Goify(name, true) +} + +// releasedCompositeConstructorName returns the old public constructor name for +// an array or map body. +func (c *wireTypeCatalog) releasedCompositeConstructorName(body *expr.AttributeExpr, policy wireTypePolicy) string { + return "New" + c.releasedCompositeName(body, policy) +} + +// Name returns the type name selected for this HTTP attribute copy. +func (s *wireAttributeScope) Name(attribute *expr.AttributeExpr, pkg string, pointer, useDefault bool) string { + if record := s.record(attribute); record != nil { + if pkg == "" { + return record.name + } + return pkg + "." + record.name + } + if union, ok := attribute.Type.(*expr.Union); ok { + if record := s.unionRecord(union); record != nil { + if pkg == "" { + return record.name + } + return pkg + "." + record.name + } + } + switch actual := attribute.Type.(type) { + case expr.Primitive: + if name, _ := codegen.GetMetaType(attribute); name != "" { + return name + } + return codegen.GoNativeTypeName(actual) + case *expr.Array, *expr.Map, *expr.Object: + context := &codegen.AttributeContext{ + Pointer: pointer, + UseDefault: useDefault, + Scope: s, + UnionPointer: true, + ArrayElementPointer: s.policy.arrayElementPointer, + } + return goTypeDefForContext(attribute, context) + case expr.UserType: + panic(fmt.Sprintf("HTTP type %q has no package declaration", wireTypeDeclaredName(actual))) + default: + return s.base.Name(attribute, pkg, pointer, useDefault) + } +} + +// Ref returns the pointer or value spelling for this HTTP attribute copy. +func (s *wireAttributeScope) Ref(attribute *expr.AttributeExpr, pkg string) string { + return wireTypeRef(s.Name(attribute, pkg, s.policy.pointer, s.policy.useDefault), attribute.Type) +} + +// Field returns the generated Go field for an HTTP attribute. +func (*wireAttributeScope) Field(attribute *expr.AttributeExpr, name string, firstUpper bool) string { + return codegen.GoifyAtt(attribute, name, firstUpper) +} + +// Package returns the Go package name written before the type for attribute. +func (s *wireAttributeScope) Package(attribute *expr.AttributeExpr) string { + if location := codegen.UserTypeLocation(attribute.Type); location != nil { + return location.PackageName() + } + return s.pkg +} + +// Enter returns type names with the Go package name needed by nested fields. +func (s *wireAttributeScope) Enter(attribute *expr.AttributeExpr) codegen.Attributor { + pkg := s.pkg + if location := codegen.UserTypeLocation(attribute.Type); location != nil { + pkg = location.PackageName() + } + policy := s.policy + viewRoot := s.viewRoot + if policy.view != "" && (s.viewRoot == nil || !wireTypeIdentitiesEqual( + s.viewRoot.identity, + newWireTypeIdentity(attribute, s.viewRoot.identity.role, policy, s.viewRoot.identity.preferred), + )) { + policy.view = "" + viewRoot = nil + } + return &wireAttributeScope{ + catalog: s.catalog, + base: s.base.Enter(attribute), + pkg: pkg, + policy: policy, + viewRoot: viewRoot, + exactOccurrence: s.exactOccurrence, + } +} + +// IsSumType reports that HTTP unions use generated values that hold one branch. +func (*wireAttributeScope) IsSumType() bool { + return true +} + +// ValidatorCall returns the exact private call used for a named value inside +// another HTTP body value. +func (s *wireAttributeScope) ValidatorCall(attribute *expr.AttributeExpr, _, target, path string) string { + if record := s.record(attribute); record != nil { + if record.nestedValidator == nil { + panic(fmt.Sprintf("HTTP type %q has no nested validator", record.name)) + } + return fmt.Sprintf("%s(%s, %s)", record.nestedValidator.Name(), target, path) + } + if userType, ok := attribute.Type.(expr.UserType); ok { + panic(fmt.Sprintf("HTTP validator for %q has no package declaration", wireTypeDeclaredName(userType))) + } + return s.base.ValidatorCall(attribute, "", target, path) +} + +// record returns the chosen type for attribute. A copied nested value may reuse +// a type when its pointer and default-value rules are the same. +func (s *wireAttributeScope) record(attribute *expr.AttributeExpr) *wireTypeRecord { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return nil + } + if s.exactOccurrence { + return s.catalog.bindings[attribute] + } + preferred := wireTypePreferredName(userType, s.policy) + if s.viewRoot != nil { + identity := newWireTypeIdentity(attribute, s.viewRoot.identity.role, s.policy, s.viewRoot.identity.preferred) + if wireTypeIdentitiesEqual(s.viewRoot.identity, identity) { + return s.viewRoot + } + } + identity := newWireTypeIdentity(attribute, wireAttribute, s.policy, preferred) + if record := s.catalog.bindings[attribute]; record != nil && wireTypeIdentitiesEqual(record.identity, identity) { + return record + } + return s.catalog.find(identity) +} + +// unionRecord returns the chosen union for union. A copied nested union may +// reuse it when its pointer and default-value rules are the same. +func (s *wireAttributeScope) unionRecord(union *expr.Union) *wireUnionRecord { + if record := s.catalog.unionBindings[union]; record != nil { + return record + } + return s.catalog.findUnion(s.catalog.unionIdentity(union, wireAttribute, s.policy)) +} + +// Scope returns the list used to keep local variable names unique. +func (s *wireAttributeScope) Scope() *codegen.NameScope { + return s.base.Scope() +} + +// findUnion returns the package record for the same generated union. +func (c *wireTypeCatalog) findUnion(identity wireUnionIdentity) *wireUnionRecord { + for _, record := range c.unions { + if wireUnionIdentitiesEqual(record.identity, identity) { + return record + } + } + return nil +} + +// wireUnionIdentitiesEqual reports whether two unions use the same definition +// and the same generated branch types. +func wireUnionIdentitiesEqual(left, right wireUnionIdentity) bool { + return left.definition == right.definition && slices.Equal(left.declarations, right.declarations) +} + +// newWireTypeIdentity records the designed value and the rules that determine +// its generated Go type. +func newWireTypeIdentity(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string) wireTypeIdentity { + identity := wireTypeIdentity{role: role, preferred: preferred, attribute: expr.DupAtt(attribute), policy: policy} + if userType, ok := attribute.Type.(expr.UserType); ok { + if resultType, ok := userType.(*expr.ResultTypeExpr); ok { + identity.resultID = resultType.Identifier + identity.role = 0 + identity.policy.view = "" + } else if policy.view == "" { + identity.sourceID = userType.Origin().ID() + identity.role = 0 + } + } + return identity +} + +// wireTypePreferredName returns the Go name requested for one designed type. +// A result type created for one view already has the view in its name. Other +// values use their authored type name unless selecting a view changes the +// fields in the generated transport type. +func wireTypePreferredName(userType expr.UserType, policy wireTypePolicy) string { + named := userType.Origin() + if _, projected := userType.(*expr.ResultTypeExpr); projected || policy.view != "" { + named = userType + } + return codegen.Goify(wireTypeDeclaredName(named), true) +} + +// releasedWireTypeSuffix returns the suffix that HTTP copies added to named +// values nested inside one body. The body declaration itself already keeps its +// endpoint name. +func releasedWireTypeSuffix(attribute *expr.AttributeExpr, role wireTypeRole) string { + switch role { + case wireRequestBody: + return "RequestBody" + case wireStreamPayload: + if expr.IsObject(attribute.Type) { + return "StreamingBody" + } + return "" + case wireResponseBody: + if expr.IsObject(attribute.Type) { + return "ResponseBody" + } + return "Response" + default: + return "" + } +} + +// wireTypeIdentitiesEqual reports whether two records produce the same Go type. +func wireTypeIdentitiesEqual(left, right wireTypeIdentity) bool { + if left.sourceID != right.sourceID || left.resultID != right.resultID || left.role != right.role || left.preferred != right.preferred || !wireTypePoliciesEqual(left.policy, right.policy) { + return false + } + if left.sourceID != "" { + leftType := left.attribute.Type.(expr.UserType) + rightType := right.attribute.Type.(expr.UserType) + return wireAttributesEqual(leftType.Attribute(), rightType.Attribute(), make(map[wireAttributePair]struct{})) + } + if leftType, ok := left.attribute.Type.(expr.UserType); ok { + rightType, ok := right.attribute.Type.(expr.UserType) + return ok && wireAttributesEqual(leftType.Attribute(), rightType.Attribute(), make(map[wireAttributePair]struct{})) + } + return wireAttributesEqual(left.attribute, right.attribute, make(map[wireAttributePair]struct{})) +} + +// order returns the designed values used to choose a stable suffix when several +// HTTP declarations ask for the same Go name. +func (i wireTypeIdentity) order(kind wireNameKind) wireNameOrder { + return wireNameOrder{ + kind: kind, + api: i.api, + source: i.sourceID + i.resultID, + role: uint8(i.role), + preferred: i.preferred, + shape: expr.Hash(i.attribute.Type, false, false, false), + view: i.policy.view, + request: i.policy.request, + pointer: i.policy.pointer, + arrayElementPointer: i.policy.arrayElementPointer, + defaults: i.policy.useDefault, + } +} + +// order returns the designed values used to choose stable suffixes for a union, +// its constants, and its functions. +func (i wireUnionIdentity) order(kind wireNameKind, name, branch string) wireNameOrder { + declarations := make([]string, len(i.declarations)) + for index, declaration := range i.declarations { + order := declaration.identity.order(wireNameType) + declarations[index] = fmt.Sprintf( + "%q:%q:%d:%q:%q:%q:%t:%t:%t:%t", + order.api, + order.source, + order.role, + order.preferred, + order.shape, + order.view, + order.request, + order.pointer, + order.arrayElementPointer, + order.defaults, + ) + } + return wireNameOrder{ + kind: kind, + unionUse: i.releasedOrder, + api: i.api, + source: strings.Join(declarations, "\x00"), + preferred: name, + shape: string(i.definition), + view: branch, + } +} + +// ComparePackageName orders HTTP declarations from designed values so memory +// addresses and design reading order cannot change generated names. +func (o wireNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(wireNameOrder) + for _, compared := range []int{ + cmp.Compare(o.kind, right.kind), + cmp.Compare(o.unionUse, right.unionUse), + cmp.Compare(o.api, right.api), + cmp.Compare(o.source, right.source), + cmp.Compare(o.target, right.target), + cmp.Compare(o.role, right.role), + cmp.Compare(o.preferred, right.preferred), + cmp.Compare(o.shape, right.shape), + cmp.Compare(o.view, right.view), + cmp.Compare(boolOrder(o.request), boolOrder(right.request)), + cmp.Compare(boolOrder(o.pointer), boolOrder(right.pointer)), + cmp.Compare(boolOrder(o.arrayElementPointer), boolOrder(right.arrayElementPointer)), + cmp.Compare(boolOrder(o.defaults), boolOrder(right.defaults)), + cmp.Compare(boolOrder(o.required), boolOrder(right.required)), + } { + if compared != 0 { + return compared + } + } + return 0 +} + +// boolOrder converts false to zero and true to one for name ordering. +func boolOrder(value bool) uint8 { + if value { + return 1 + } + return 0 +} + +// wireTypePoliciesEqual compares only rules that change a Go type definition. +// A validation function does not create a second type when every field is the +// same. +func wireTypePoliciesEqual(left, right wireTypePolicy) bool { + left.validate = false + right.validate = false + return left == right +} + +// wireAttributesEqual compares the designed facts that change a generated type +// or its validation function. It handles types that refer back to themselves. +func wireAttributesEqual(left, right *expr.AttributeExpr, seen map[wireAttributePair]struct{}) bool { + if left == right { + return true + } + pair := wireAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if !reflect.DeepEqual(left.DefaultValue, right.DefaultValue) || !reflect.DeepEqual(left.Validation, right.Validation) || !wireMetadataEqual(left.Meta, right.Meta) { + return false + } + switch ltype := left.Type.(type) { + case expr.UserType: + rtype, ok := right.Type.(expr.UserType) + if !ok { + return false + } + if lresult, ok := ltype.(*expr.ResultTypeExpr); ok { + rresult, ok := rtype.(*expr.ResultTypeExpr) + return ok && lresult.Identifier == rresult.Identifier && lresult.Name() == rresult.Name() && wireAttributesEqual(lresult.Attribute(), rresult.Attribute(), seen) + } + return ltype.Origin() == rtype.Origin() && wireAttributesEqual(ltype.Attribute(), rtype.Attribute(), seen) + case *expr.Object: + rtype, ok := right.Type.(*expr.Object) + if !ok || len(*ltype) != len(*rtype) { + return false + } + for index, field := range *ltype { + other := (*rtype)[index] + if field.Name != other.Name || !wireAttributesEqual(field.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Array: + rtype, ok := right.Type.(*expr.Array) + return ok && ltype.NonNullableElems == rtype.NonNullableElems && wireAttributesEqual(ltype.ElemType, rtype.ElemType, seen) + case *expr.Map: + rtype, ok := right.Type.(*expr.Map) + return ok && wireAttributesEqual(ltype.KeyType, rtype.KeyType, seen) && wireAttributesEqual(ltype.ElemType, rtype.ElemType, seen) + case *expr.Union: + rtype, ok := right.Type.(*expr.Union) + if !ok || ltype.Name() != rtype.Name() || ltype.GetTypeKey() != rtype.GetTypeKey() || ltype.GetValueKey() != rtype.GetValueKey() || len(ltype.Values) != len(rtype.Values) { + return false + } + for index, branch := range ltype.Values { + other := rtype.Values[index] + if branch.Name != other.Name || !wireAttributesEqual(branch.Attribute, other.Attribute, seen) { + return false + } + } + return true + default: + return left.Type.Kind() == right.Type.Kind() && left.Type.Name() == right.Type.Name() + } +} + +// wireMetadataEqual compares design metadata while ignoring the Go name added later. +func wireMetadataEqual(left, right expr.MetaExpr) bool { + keys := make([]string, 0, len(left)) + for key := range left { + if key != "struct:type:name" { + keys = append(keys, key) + } + } + slices.Sort(keys) + otherKeys := make([]string, 0, len(right)) + for key := range right { + if key != "struct:type:name" { + otherKeys = append(otherKeys, key) + } + } + slices.Sort(otherKeys) + if !slices.Equal(keys, otherKeys) { + return false + } + for _, key := range keys { + if !slices.Equal(left[key], right[key]) { + return false + } + } + return true +} + +// sortedWireAttributes keeps generated Go names stable when object fields are +// listed in a different order. +func sortedWireAttributes(attributes []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { + sorted := slices.Clone(attributes) + slices.SortFunc(sorted, func(left, right *expr.NamedAttributeExpr) int { + return strings.Compare(left.Name, right.Name) + }) + return sorted +} + +// wireTypeDeclaredName returns the type name written in the design instead of a +// generated Go name saved in metadata. +func wireTypeDeclaredName(userType expr.UserType) string { + switch actual := userType.(type) { + case *expr.UserTypeExpr: + return actual.TypeName + case *expr.ResultTypeExpr: + return actual.TypeName + default: + panic(fmt.Sprintf("unsupported HTTP wire user type %T", userType)) + } +} diff --git a/http/codegen/wire_catalog_test.go b/http/codegen/wire_catalog_test.go new file mode 100644 index 0000000000..128856e04a --- /dev/null +++ b/http/codegen/wire_catalog_test.go @@ -0,0 +1,379 @@ +// This file verifies copied HTTP request and response types receive the right +// Go names when their source types, field rules, or generated functions differ. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +func TestWireTypeCatalogIdentity(t *testing.T) { + request := wireTypePolicy{request: true, pointer: true} + response := wireTypePolicy{useDefault: true} + first := wireCatalogType("Shared", "same", "first", true) + second := wireCatalogType("Shared", "same", "second", false) + + catalog, generation := testWireTypeCatalog(t) + firstBody := makeHTTPType(&expr.AttributeExpr{Type: first}) + catalog.collect(firstBody, wireRequestBody, request) + catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: first}), wireRequestBody, request) + catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: second}), wireRequestBody, request) + catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: first}), wireResponseBody, response) + linkTestWireTypeCatalog(t, generation, catalog) + firstRecord := catalog.lookupUser(firstBody, wireRequestBody, request) + reusedRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: first}), wireRequestBody, request) + secondRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: second}), wireRequestBody, request) + responseRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: first}), wireResponseBody, response) + + require.Same(t, firstRecord, reusedRecord) + require.Len(t, map[string]struct{}{ + firstRecord.name: {}, secondRecord.name: {}, responseRecord.name: {}, + }, 3) +} + +func TestWireTypeCatalogRecursiveIdentityTerminates(t *testing.T) { + recursive := &expr.UserTypeExpr{TypeName: "Node", UID: "node"} + object := &expr.Object{} + recursive.AttributeExpr = &expr.AttributeExpr{Type: object} + object.Set("next", &expr.AttributeExpr{Type: recursive}) + + catalog, generation := testWireTypeCatalog(t) + body := makeHTTPType(&expr.AttributeExpr{Type: recursive}) + policy := wireTypePolicy{request: true, pointer: true} + catalog.collect(body, wireRequestBody, policy) + linkTestWireTypeCatalog(t, generation, catalog) + record := catalog.lookupUser(body, wireRequestBody, policy) + + require.Equal(t, "Node", record.name) + require.Len(t, catalog.records, 1) +} + +func TestWireTypeCatalogPreservesReleasedNestedNames(t *testing.T) { + cases := []struct { + name string + role wireTypeRole + body *expr.AttributeExpr + want string + }{ + { + name: "request body", + role: wireRequestBody, + body: wireCatalogContainer(wireCatalogType("Child", "request-child", "value", true)), + want: "ChildRequestBody", + }, + { + name: "streaming body", + role: wireStreamPayload, + body: wireCatalogContainer(wireCatalogType("Child", "stream-child", "value", true)), + want: "ChildStreamingBody", + }, + { + name: "object response body", + role: wireResponseBody, + body: wireCatalogContainer(wireCatalogType("Child", "response-child", "value", true)), + want: "ChildResponseBody", + }, + { + name: "collection response body", + role: wireResponseBody, + body: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{ + Type: wireCatalogType("Child", "response-element", "value", true), + }}}, + want: "ChildResponse", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + catalog, generation := testWireTypeCatalog(t) + catalog.collect(test.body, test.role, wireTypePolicy{}) + linkTestWireTypeCatalog(t, generation, catalog) + + child := firstWireUserType(test.body) + record := catalog.lookupUser(child, wireAttribute, wireTypePolicy{}) + require.Equal(t, test.want, record.name) + }) + } +} + +func TestWireTypeCatalogKeepsCurrentNameForSharedReleasedDeclarations(t *testing.T) { + child := wireCatalogType("Shared", "shared", "value", true) + request := wireCatalogContainer(child) + stream := wireCatalogContainer(child) + catalog, generation := testWireTypeCatalog(t) + + catalog.collect(request, wireRequestBody, wireTypePolicy{}) + catalog.collect(stream, wireStreamPayload, wireTypePolicy{}) + linkTestWireTypeCatalog(t, generation, catalog) + + record := catalog.lookupUser(firstWireUserType(request), wireAttribute, wireTypePolicy{}) + require.Equal(t, "Shared", record.name) +} + +func TestWireTypeCatalogSuffixesReleasedNameAfterPackageCollision(t *testing.T) { + body := wireCatalogContainer(wireCatalogType("Child", "child", "value", true)) + catalog, generation := testWireTypeCatalog(t, "ChildRequestBody") + catalog.collect(body, wireRequestBody, wireTypePolicy{}) + linkTestWireTypeCatalog(t, generation, catalog) + + record := catalog.lookupUser(firstWireUserType(body), wireAttribute, wireTypePolicy{}) + require.Equal(t, "ChildRequestBody2", record.name) +} + +func TestWireTypeCatalogSeparatesDeclarationIdentityFromValidatorPlacement(t *testing.T) { + typeAttribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + withoutValidator := wireTypePolicy{pointer: true} + withValidator := wireTypePolicy{pointer: true, validate: true} + catalog, generation := testWireTypeCatalog(t) + + first := catalog.collect(typeAttribute, wireResponseBody, withoutValidator) + second := catalog.collect(expr.DupAtt(typeAttribute), wireAttribute, withValidator) + catalog.addValidationRoot(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "shared", Attribute: expr.DupAtt(typeAttribute)}, + }}, withValidator) + + require.Same(t, first, second) + linkTestWireTypeCatalog(t, generation, catalog) + catalog.bind(first, &TypeData{Def: "struct { Value string }"}) + catalog.bind(second, &TypeData{ + Def: "struct { Value string }", + ValidateDef: "validate shared from body", + NestedValidateDef: "validate shared from parent path", + }) + require.Equal(t, "Shared", first.name) + require.Equal(t, "validate shared from body", first.data.ValidateDef) + require.Equal(t, "validate shared from parent path", first.data.NestedValidateDef) + require.Equal(t, "ValidateShared", first.data.ValidatorName) + require.Equal(t, "validateShared", first.data.NestedValidatorName) +} + +func TestWireTypeCatalogErrorDescriptionUsesAllPlannedErrors(t *testing.T) { + cases := []struct { + name string + uses []wireErrorUse + want string + }{ + { + name: "same endpoint", + uses: []wireErrorUse{ + {service: "Calc", method: "Add", name: "underflow"}, + {service: "Calc", method: "Add", name: "overflow"}, + }, + want: "Shared is the type of the \"Calc\" service \"Add\" endpoint HTTP response body for the \"overflow\" and \"underflow\" errors.", + }, + { + name: "several endpoints", + uses: []wireErrorUse{ + {service: "Beta", method: "Write", name: "conflict"}, + {service: "Alpha", method: "Read", name: "missing"}, + }, + want: "Shared is the HTTP response body type for these service errors:\n" + + "- \"Alpha\" service \"Read\" endpoint: \"missing\" error\n" + + "- \"Beta\" service \"Write\" endpoint: \"conflict\" error", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + policy := wireTypePolicy{pointer: true} + catalog, generation := testWireTypeCatalog(t) + record := catalog.collect(attribute, wireResponseBody, policy) + for _, use := range test.uses { + record.addErrorUse(use) + } + record.addErrorUse(test.uses[0]) + + linkTestWireTypeCatalog(t, generation, catalog) + catalog.bind(record, &TypeData{ + Description: "Shared is an HTTP response body.", + Def: "struct { Value string }", + }) + + require.Equal(t, test.want, record.data.Description) + }) + } +} + +func TestWireTypeCatalogPlansNestedValidatorNameWithPackageNames(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + policy := wireTypePolicy{pointer: true, validate: true} + catalog, generation := testWireTypeCatalog(t, "validateShared") + record := catalog.collect(attribute, wireAttribute, policy) + catalog.addValidationRoot(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "shared", Attribute: expr.DupAtt(attribute)}, + }}, policy) + + linkTestWireTypeCatalog(t, generation, catalog) + catalog.bind(record, &TypeData{ + ValidateDef: "validate shared from body", + NestedValidateDef: "validate shared from parent path", + }) + + require.Equal(t, "validateShared2", record.data.NestedValidatorName) +} + +func TestWireTypeCatalogDoesNotRewriteValidationCalls(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + policy := wireTypePolicy{pointer: true, validate: true} + catalog, generation := testWireTypeCatalog(t, "ValidateShared") + record := catalog.collect(attribute, wireAttribute, policy) + linkTestWireTypeCatalog(t, generation, catalog) + + catalog.bind(record, &TypeData{ + ValidateDef: "validate shared from body", + ValidateRef: "err = ValidateSharedCopy(v)", + }) + + require.Equal(t, "ValidateShared2", record.data.ValidatorName) + require.Equal(t, "err = ValidateSharedCopy(v)", record.data.ValidateRef) +} + +func TestWireTypeCatalogCollectsUnionsBeforeFreeze(t *testing.T) { + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "count", Attribute: &expr.AttributeExpr{Type: expr.Int}}, + }, + } + attribute := &expr.AttributeExpr{Type: union} + catalog, generation := testWireTypeCatalog(t) + + catalog.collect(attribute, wireAttribute, wireTypePolicy{}) + require.Len(t, catalog.unionOccurrences, 1) + linkTestWireTypeCatalog(t, generation, catalog) + catalog.applyNames(attribute, wireAttribute, wireTypePolicy{}) + require.Len(t, catalog.unions, 1) + require.NotNil(t, catalog.unions[0].data) +} + +func TestWireTypeCatalogLookupDoesNotDeriveIdentityFromAssignedName(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + policy := wireTypePolicy{pointer: true} + catalog, generation := testWireTypeCatalog(t, "Shared") + catalog.collect(attribute, wireAttribute, policy) + linkTestWireTypeCatalog(t, generation, catalog) + + first := catalog.lookupUser(attribute, wireAttribute, policy) + second := catalog.lookupUser(attribute, wireAttribute, policy) + + require.Same(t, first, second) + require.Equal(t, "Shared2", first.name) +} + +func TestWireTypeCatalogBindingUsesCurrentLayoutPolicy(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + valuePolicy := wireTypePolicy{} + pointerPolicy := wireTypePolicy{pointer: true} + catalog, generation := testWireTypeCatalog(t) + catalog.collect(attribute, wireAttribute, valuePolicy) + catalog.collect(attribute, wireAttribute, pointerPolicy) + linkTestWireTypeCatalog(t, generation, catalog) + + valueRecord := catalog.lookupUser(attribute, wireAttribute, valuePolicy) + pointerRecord := catalog.lookupUser(attribute, wireAttribute, pointerPolicy) + require.NotSame(t, valueRecord, pointerRecord) + + valueScope := catalog.resolver(catalog.scope, valuePolicy) + pointerScope := catalog.resolver(catalog.scope, pointerPolicy) + require.Equal(t, valueRecord.name, valueScope.Name(attribute, "", false, false)) + require.Equal(t, pointerRecord.name, pointerScope.Name(attribute, "", true, false)) +} + +func TestWireTypeCatalogDoesNotNameTheSharedEmptySentinel(t *testing.T) { + originalNil := expr.Empty.Attribute().Meta == nil + original := expr.Empty.Attribute().Meta.Dup() + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "empty", Attribute: &expr.AttributeExpr{Type: expr.Empty}}, + }} + catalog, generation := testWireTypeCatalog(t) + + catalog.collect(attribute, wireAttribute, wireTypePolicy{}) + linkTestWireTypeCatalog(t, generation, catalog) + catalog.applyNames(attribute, wireAttribute, wireTypePolicy{}) + + if originalNil { + require.Nil(t, expr.Empty.Attribute().Meta) + } else { + require.Equal(t, original, expr.Empty.Attribute().Meta) + } +} + +func TestWireTypeCatalogRejectsLateAndUnknownDeclarations(t *testing.T) { + typeAttribute := &expr.AttributeExpr{Type: wireCatalogType("Known", "known", "value", true)} + policy := wireTypePolicy{request: true, pointer: true} + catalog, generation := testWireTypeCatalog(t) + catalog.collect(typeAttribute, wireRequestBody, policy) + linkTestWireTypeCatalog(t, generation, catalog) + + require.Panics(t, func() { + catalog.collect(&expr.AttributeExpr{Type: wireCatalogType("Late", "late", "value", true)}, wireRequestBody, policy) + }) + require.Panics(t, func() { + catalog.lookupUser(&expr.AttributeExpr{Type: wireCatalogType("Unknown", "unknown", "value", true)}, wireRequestBody, policy) + }) + require.Panics(t, func() { + catalog.scope.Unique("late") + }) +} + +// testWireTypeCatalog creates the generated package that assigns names for a test. +// Reserved names simulate declarations contributed by another generator. +func testWireTypeCatalog(t *testing.T, reserved ...string) (*wireTypeCatalog, *codegen.Generation) { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("generated.local/gen/http/test/client") + require.NoError(t, err) + for _, name := range reserved { + require.NoError(t, pkg.DeclareName(codegen.NewExactName(codegen.NameVariable, name))) + } + return newWireTypeCatalog(pkg), generation +} + +// linkTestWireTypeCatalog submits the collected declarations, asks the +// generation to assign all package names, and makes those names available to +// the test. +func linkTestWireTypeCatalog(t *testing.T, generation *codegen.Generation, catalog *wireTypeCatalog) { + t.Helper() + require.NoError(t, catalog.Declare()) + require.NoError(t, generation.Freeze()) + catalog.Link() +} + +// wireCatalogType builds an independent declared type. Equal UIDs are +// intentional because the original declared type, not the example ID, selects +// the copied HTTP type. +func wireCatalogType(name, uid, field string, required bool) *expr.UserTypeExpr { + attribute := &expr.AttributeExpr{Type: expr.String} + attribute.Validation = &expr.ValidationExpr{Pattern: field} + object := &expr.Object{{Name: field, Attribute: attribute}} + if required { + objectAttribute := &expr.AttributeExpr{Type: object, Validation: &expr.ValidationExpr{Required: []string{field}}} + return &expr.UserTypeExpr{AttributeExpr: objectAttribute, TypeName: name, UID: uid} + } + return &expr.UserTypeExpr{AttributeExpr: &expr.AttributeExpr{Type: object}, TypeName: name, UID: uid} +} + +// wireCatalogContainer places a named type inside an object body. +func wireCatalogContainer(child expr.UserType) *expr.AttributeExpr { + return &expr.AttributeExpr{Type: &expr.Object{{ + Name: "child", + Attribute: &expr.AttributeExpr{Type: child}, + }}} +} + +// firstWireUserType returns the first named value inside an object or array body. +func firstWireUserType(body *expr.AttributeExpr) *expr.AttributeExpr { + switch actual := body.Type.(type) { + case *expr.Object: + return (*actual)[0].Attribute + case *expr.Array: + return actual.ElemType + default: + panic("test body does not contain a named type") + } +} diff --git a/jsonrpc/ARCHITECTURE.md b/jsonrpc/ARCHITECTURE.md index f2930a7ae2..7c20f1eb8d 100644 --- a/jsonrpc/ARCHITECTURE.md +++ b/jsonrpc/ARCHITECTURE.md @@ -1,201 +1,103 @@ # Goa JSON-RPC Architecture -This document explains the architecture of Goa's JSON-RPC support, covering both basic HTTP and advanced WebSocket-based streaming communication. It details the code generation process, runtime behavior, and recommended usage patterns. +Goa implements JSON-RPC 2.0 over one HTTP POST route per service. A method +either returns one result in the HTTP response or streams results as JSON-RPC +messages carried by Server-Sent Events (SSE). -## Core Principle: Composition Over Modification +JSON-RPC does not support Goa client streams or bidirectional streams. A method +with `StreamingResult` must select `ServerSentEvents`. Ordinary HTTP methods may +still use WebSockets; that transport has its own generated code and does not +change the JSON-RPC service contract. -The fundamental principle behind Goa's JSON-RPC implementation is **composition over modification**. Instead of altering shared HTTP templates to accommodate JSON-RPC, the JSON-RPC code generation layer builds upon the existing HTTP transport infrastructure. This approach ensures a clean separation of concerns, preventing the HTTP layer from becoming coupled to JSON-RPC specifics and allowing both to evolve independently. +## Generation layers -## Code Generation - -The generation of JSON-RPC enabled services follows a layered process that starts with the standard HTTP transport code. - -### HTTP Codegen Foundation - -The process begins by generating the transport-agnostic service code, which includes: - -* Service interfaces and endpoints -* Basic HTTP handlers and middleware -* Encoding and decoding utilities -* Error handling infrastructure - -### JSON-RPC Composition Layer - -The JSON-RPC `codegen` package then composes on top of the generated HTTP code by programmatically manipulating the `codegen.File` data structure before it is rendered. This involves a three-step process: - -1. **Generate Base HTTP Code**: The standard `httpcodegen.ServerEncodeDecodeFile` function is called to produce the initial set of files. -2. **Modify Sections**: The generated sections are iterated upon to introduce JSON-RPC specific behavior. This includes adding necessary imports and replacing HTTP handler signatures with their JSON-RPC counterparts. -3. **Add JSON-RPC Sections**: Finally, new sections containing JSON-RPC specific logic, such as server handler initializers, are appended. - -This process is exemplified by the following snippet: +The generated service package owns transport-neutral method and stream +interfaces. JSON-RPC uses the same exact typed per-method server stream as HTTP +SSE and gRPC: ```go -// Step 1: Generate base HTTP code -f := httpcodegen.ServerEncodeDecodeFile(genpkg, svc, data) - -// Step 2: Modify sections before final code generation -for _, s := range f.SectionTemplates { - // Add JSON-RPC imports - if s.Name == "source-header" { - codegen.AddImport(s, codegen.GoaImport("jsonrpc")) - } - - // Modify signatures for JSON-RPC context - if s.Name == "request-decoder" { - s.Source = strings.Replace(s.Source, - httpRequestDecoderTemplate, - jsonrpcRequestDecoderTemplate, 1) - } - - // Namespace sections to avoid conflicts - s.Name = "jsonrpc-" + s.Name +type WatchServerStream interface { + Send(Event) error + SendWithContext(context.Context, Event) error + Close() error } - -// Step 3: Add JSON-RPC specific sections -sections = append(sections, - &codegen.SectionTemplate{ - Name: "jsonrpc-server-handler-init", - Source: jsonrpcTemplates.Read(serverHandlerInitT), - Data: e - }) ``` -### Key Codegen Patterns - -Three key patterns enable this compositional approach: - -1. **Template Namespacing**: JSON-RPC sections are prefixed with `jsonrpc-` to prevent name collisions with HTTP sections. -2. **In-Memory Modification**: Instead of altering the source templates on disk, modifications are made to the `Source` field of the `codegen.SectionTemplate` struct in memory. -3. **Conditional Template Selection**: The code generation logic dynamically selects the appropriate templates based on the endpoint configuration, for example, adding WebSocket-specific templates only when a WebSocket transport is defined for the service. - -### Template Responsibilities - -This layered approach results in a clean separation of responsibilities between HTTP and JSON-RPC templates: - -* **HTTP Templates (Shared)**: These are responsible for transport-agnostic service logic. They must not contain any JSON-RPC or WebSocket specific logic. -* **JSON-RPC Templates (Specialized)**: These handle JSON-RPC protocol specifics and WebSocket streaming. They can specialize HTTP behavior but should do so through composition, not modification of the HTTP templates. - -## Runtime Architecture and Usage - -The generated code provides two primary mechanisms for JSON-RPC communication: a simple HTTP transport for traditional request-response interactions, and a WebSocket transport for real-time, bidirectional streaming. - -### Standard JSON-RPC over HTTP - -For services that do not require streaming, JSON-RPC messages are exchanged over standard HTTP. The generated server code includes an HTTP handler that decodes the JSON-RPC request from the HTTP body, invokes the corresponding service method, and writes the JSON-RPC response back to the HTTP response writer. - -The handler signatures clearly illustrate the differences between the transport layers: - -* **Regular HTTP**: `func(context.Context, *http.Request, http.ResponseWriter)` -* **JSON-RPC HTTP**: `func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter)` +The HTTP generation plan owns JSON request and response body types, +conversions, validation, and file imports. The JSON-RPC generator copies the +finished HTTP plan and adds JSON-RPC request dispatch and message framing. It +does not change the service interface or inspect generated types at runtime. -### JSON-RPC over WebSocket +Generation follows this order: -Goa provides a powerful abstraction for building streaming services with WebSockets. This allows for full-duplex communication channels that can support a variety of interaction patterns. +1. The design is evaluated and rejects stream shapes JSON-RPC cannot carry. +2. The service plan assigns exact method and stream types. +3. The HTTP JSON-RPC plan assigns request and response body types and + conversions. +4. The JSON-RPC plan writes only the unary or SSE code selected by the design. -#### Architectural Principles +## Unary requests -The WebSocket architecture is guided by three principles: +The server reads one JSON-RPC request, decodes its `params` into the designed +payload, calls the service endpoint once, and writes one JSON-RPC response when +the request contains an `id`. A request without an `id` is a notification and +receives no response. -1. **Single WebSocket Connection**: A single WebSocket connection is used to handle all JSON-RPC communication for a given service, including multiplexing different method calls and streaming patterns. -2. **User Code Owns Streaming Logic**: The core streaming logic is implemented by the developer in the `HandleStream` method. Goa provides the infrastructure and the `Stream` interface, but the implementation of the streaming strategy is left to the user. -3. **Clean Separation of Concerns**: The architecture separates the business logic (in service methods), the transport layer (JSON-RPC protocol and WebSocket management), and the streaming logic (in `HandleStream`). +The shared service route dispatches requests by their JSON-RPC `method`. Batch +requests use the same per-method handlers and collect only responses for calls +that contain an `id`. -#### Core Components +## Server-sent-event streams -The WebSocket support is built around three core components: +The client sends one JSON-RPC request over HTTP. Each service call to `Send` +writes an SSE event named `notification` whose data is a complete JSON-RPC +notification: -* **`HandleStream` Method**: This method is the entry point for all WebSocket communication. It is where the developer implements the application-specific streaming logic. - - ```go - func (s *serviceImpl) HandleStream(ctx context.Context, stream ServiceName.Stream) error { - // User implements their streaming strategy here. - // Can listen to channels, timers, events, etc. - // Can call stream.Recv() to process incoming JSON-RPC requests. - // Can call stream.SendMethodName() to send responses or notifications. - } - ``` - -* **`Stream` Interface**: This generated interface provides the methods for interacting with the WebSocket connection, including receiving requests (`Recv`), sending responses (`SendMethodName`), sending errors (`SendError`), and closing the connection (`Close`). - -* **Service Methods**: These are the regular service methods with standard Go signatures. They are called automatically when `Recv()` processes a matching JSON-RPC request and can also be called directly from `HandleStream` for server-initiated communication. - -The handler signature for WebSocket streaming endpoints reflects the asynchronous nature of the communication: - -```go -func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) +```json +{"jsonrpc":"2.0","method":"watch","params":{"value":"ready"}} ``` -The handler returns a result and an error because responses are sent asynchronously via the `Stream` interface rather than being written directly to an `http.ResponseWriter`. - -#### Streaming Patterns - -The flexibility of the `HandleStream` method allows for a variety of streaming patterns: - -* **Request-Response**: The traditional JSON-RPC pattern can be implemented by simply calling `stream.Recv()` in a loop. When `Recv()` is called, it reads a JSON-RPC request from the WebSocket, dispatches it to the appropriate service method, and automatically sends the response back. - - ```go - func (s *serviceImpl) HandleStream(ctx context.Context, stream ServiceName.Stream) error { - defer stream.Close() - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - if err := stream.Recv(ctx); err != nil { - return err - } - } - } - } - ``` +The service method return completes the stream. For a request with an `id`, the +transport writes exactly one terminal event: -* **Server Streaming**: To push data from the server to the client, the `HandleStream` method can initiate a goroutine that sends data at regular intervals or in response to events. +- a `response` with `result: null` when the method succeeds; or +- an `error` containing the mapped JSON-RPC error when the method returns an + error. -* **Client Streaming**: To receive a stream of data from a client, the `HandleStream` method can repeatedly call `stream.Recv()` and accumulate the results. +JSON-RPC rejects a method that defines different `Result` and +`StreamingResult` types because its client stream has no separate operation +that could return the final `Result`. Use one method for the stream and another +method for the final resource. gRPC has the same restriction. Ordinary HTTP +keeps mixed-result support. -* **Bidirectional Streaming**: For interactive communication, `HandleStream` can combine both server and client streaming patterns, for example by launching a goroutine to handle outgoing messages while the main loop processes incoming messages. +A request without an `id` receives streamed notifications but no terminal +response. Request IDs and JSON-RPC messages remain transport details and never +appear in the generated service stream. -#### Advanced Patterns +The generated client returns each notification value from `Recv`. A successful +terminal response makes the next `Recv` return `io.EOF`. A terminal JSON-RPC +error is returned as an error. -The `HandleStream` method can also be used to implement more advanced patterns, such as: +## Result conversion and views -* **Mixed Request-Response and Streaming**: A service can handle both traditional request-response interactions and asynchronous, server-initiated notifications within the same WebSocket connection. -* **Conditional Streaming**: The streaming strategy can be determined dynamically based on the properties of the connection or the initial messages exchanged. +All JSON names, required fields, transport pointers, result conversions, and +view branches are decided while generating code. A variable-view result uses +this JSON-RPC value: -#### Method Dispatch and Results - -When `stream.Recv()` is called, it automatically handles the parsing of the incoming JSON-RPC request, validation, routing to the appropriate service method, and marshalling of the response. Service methods can also be invoked manually from within `HandleStream` for server-initiated communication. - -#### Error Handling - -The architecture provides mechanisms for handling various types of errors: - -* **Connection Errors**: Errors at the WebSocket connection level will cause `HandleStream` to terminate. -* **JSON-RPC Protocol Errors**: Invalid requests will result in the automatic sending of a JSON-RPC error response. -* **Streaming Errors**: Errors that occur while sending or receiving data can be handled within the `HandleStream` implementation. - -#### Testing Strategies - -The separation of concerns in the architecture simplifies testing: - -* **Integration Tests**: The `HandleStream` implementation can be overridden in tests to simulate specific streaming behaviors. -* **Unit Tests**: Service methods can be tested independently as standard Go functions. - -## Maintenance Guidelines - -To maintain the clean separation of concerns and the long-term health of the codebase, it is important to adhere to the following guidelines: +```json +{"view":"summary","body":{"value":"ready"}} +``` -### DO: +The same representation is used for unary results and streamed notification +parameters. A fixed-view or non-viewed result uses only its designed body. -* ✅ Modify JSON-RPC templates for JSON-RPC specific behavior. -* ✅ Use `codegen.File` section manipulation for signature changes. -* ✅ Add JSON-RPC specific sections for specialized functionality. -* ✅ Compose on top of HTTP-generated code. +## Errors -### DON'T: +Protocol parsing, request validation, method dispatch, designed service errors, +and unexpected service errors are mapped by the JSON-RPC transport. Service +implementations return ordinary errors; they do not write protocol error +messages themselves. -* ❌ Modify HTTP templates with JSON-RPC specific logic. -* ❌ Add WebSocket conditionals to shared HTTP templates. -* ❌ Break the transport independence of the HTTP layer. -* ❌ Couple the HTTP codegen to JSON-RPC requirements. +If the request has an `id`, an SSE decode or service error becomes one terminal +JSON-RPC error event. If the request has no `id`, the server writes no response +and passes the error to the configured server error handler. diff --git a/jsonrpc/README.md b/jsonrpc/README.md index 9489912e6b..e86ae5d127 100644 --- a/jsonrpc/README.md +++ b/jsonrpc/README.md @@ -1,1006 +1,216 @@ # JSON-RPC 2.0 in Goa -Goa provides first-class, type-safe support for JSON-RPC 2.0, enabling you to build robust RPC services with the same powerful DSL used for REST and gRPC. This implementation handles all protocol complexities while preserving Goa's design-first philosophy. - -## Table of Contents - -- [Quick Start](#quick-start) -- [Core Concepts](#core-concepts) - - [Protocol Fundamentals](#protocol-fundamentals) - - [Single Endpoint Architecture](#single-endpoint-architecture) - - [Request vs Notification](#request-vs-notification) -- [Defining Services](#defining-services) - - [Service Configuration](#service-configuration) - - [Method Configuration](#method-configuration) - - [ID Field Mapping](#id-field-mapping) -- [Transport Options](#transport-options) - - [HTTP: Request-Response](#http-request-response) - - [Server-Sent Events: Server Streaming](#server-sent-events-server-streaming) - - [WebSocket: Bidirectional Streaming](#websocket-bidirectional-streaming) - - [Mixed Transports: Content Negotiation](#mixed-transports-content-negotiation) -- [Advanced Features](#advanced-features) - - [Batch Processing](#batch-processing) - - [Error Handling](#error-handling) - - [Streaming Patterns](#streaming-patterns) - - [Mixed Results](#mixed-results) -- [Best Practices](#best-practices) - -## Quick Start - -Define a simple JSON-RPC calculator service: +Goa generates typed JSON-RPC 2.0 clients and servers from the same service +designs used for HTTP and gRPC. Generated code owns request decoding, +validation, method dispatch, response encoding, errors, notifications, batch +requests, and server-sent-event streams. -```go -// design/design.go -package design - -import . "goa.design/goa/v3/dsl" - -var _ = API("calculator", func() { - Title("Calculator Service") - Description("A simple calculator exposed via JSON-RPC") -}) - -var _ = Service("calc", func() { - Description("The calc service performs basic arithmetic") - - // Enable JSON-RPC for this service at /rpc endpoint - JSONRPC(func() { - POST("/rpc") - }) - - // Define an add method - Method("add", func() { - Description("Add two numbers") - Payload(func() { - Attribute("a", Float64, "First operand") - Attribute("b", Float64, "Second operand") - Required("a", "b") - }) - Result(Float64) - - // Expose this method via JSON-RPC - JSONRPC(func() {}) - }) - - // Define a divide method with error handling - Method("divide", func() { - Description("Divide two numbers") - Payload(func() { - Field(1, "dividend", Float64, "The dividend") - Field(2, "divisor", Float64, "The divisor") - Required("dividend", "divisor") - }) - Result(Float64) - Error("division_by_zero") - - JSONRPC(func() { - Response("division_by_zero", func() { - Code(-32001) // Custom error code - }) - }) - }) -}) -``` +## Unary methods -Generate the code: - -```bash -goa gen calculator/design -``` - -Implement the service: +Enable JSON-RPC on a service and expose each method that should be callable: ```go -// calc.go -package calcapi - -import ( - "context" - calc "calculator/gen/calc" -) - -type calcService struct{} - -func NewCalc() calc.Service { - return &calcService{} -} - -func (s *calcService) Add(ctx context.Context, p *calc.AddPayload) (float64, error) { - return p.A + p.B, nil -} - -func (s *calcService) Divide(ctx context.Context, p *calc.DividePayload) (float64, error) { - if p.Divisor == 0 { - return 0, calc.MakeDivisionByZero("cannot divide by zero") - } - return p.Dividend / p.Divisor, nil -} -``` - -## Core Concepts - -### Protocol Fundamentals - -JSON-RPC 2.0 is a stateless, lightweight remote procedure call protocol that -uses JSON for encoding. Key characteristics: - -1. **Transport Agnostic**: While commonly used over HTTP, the protocol itself doesn't specify transport -2. **Simple Message Format**: All communication uses a consistent JSON structure -3. **Bidirectional**: Supports both client-to-server and server-to-client communication -4. **Batch Support**: Multiple calls can be sent in a single request - -Message structure: -```json -// Request -{ - "jsonrpc": "2.0", - "method": "add", - "params": {"a": 5, "b": 3}, - "id": 1 -} +var _ = Service("calc", func() { + JSONRPC(func() { + POST("/rpc") + }) -// Response -{ - "jsonrpc": "2.0", - "result": 8, - "id": 1 -} + Method("add", func() { + Payload(func() { + Attribute("a", Int) + Attribute("b", Int) + Required("a", "b") + }) + Result(Int) + JSONRPC(func() {}) + }) +}) ``` -### Single Endpoint Architecture - -Unlike REST where each resource has its own URL, JSON-RPC services multiplex all -methods through a single endpoint: - -- **REST**: `/users` (GET), `/users/{id}` (GET/PUT/DELETE), `/products` (GET/POST) -- **JSON-RPC**: `/rpc` (all methods) - -This design provides several benefits: - -1. **Simplified Routing**: No complex URL patterns to manage -2. **Protocol Consistency**: All methods follow the same calling convention -3. **Connection Efficiency**: WebSocket/SSE connections can handle multiple methods -4. **Easy Versioning**: Version the entire API at once - -The `method` field in the JSON-RPC payload determines which service method to invoke: +Every JSON-RPC method in the service shares the service route. The `method` +property inside the JSON-RPC request selects the Goa method: ```json -{"jsonrpc": "2.0", "method": "add", "params": {"a": 5, "b": 3}, "id": 1} -{"jsonrpc": "2.0", "method": "divide", "params": {"dividend": 10, "divisor": 2}, "id": 2} +{"jsonrpc":"2.0","id":"sum-1","method":"add","params":{"a":2,"b":3}} ``` -### Request vs Notification - -JSON-RPC distinguishes between two types of messages based on the presence of an ID: - -**Requests** (with ID) expect a response: -```json -{"jsonrpc": "2.0", "method": "process", "params": {"data": "hello"}, "id": "req-123"} -// Server MUST send a response with matching ID -``` +The generated server validates `params`, calls the service method once, and +returns the designed result: -**Notifications** (without ID) are fire-and-forget: ```json -{"jsonrpc": "2.0", "method": "log", "params": {"message": "user logged in"}} -// Server MUST NOT send a response +{"jsonrpc":"2.0","id":"sum-1","result":5} ``` -This behavior is determined at **runtime** by the client, not design time. The -same method can be called as either a request or notification. +Calling `JSONRPC` inside a method automatically enables JSON-RPC for its +service. A service-level `JSONRPC` block is still useful for declaring the +shared route and defaults. -## Defining Services +## Requests, notifications, and IDs -### Service Configuration +A JSON-RPC request contains an `id` and receives one response. A notification +omits `id` and receives no response, including when decoding or service work +fails. -Enable JSON-RPC at the service level to define the shared endpoint: +Goa can map the protocol ID to a designed payload field: ```go -Service("myservice", func() { - Description("A service exposed via JSON-RPC") - - // Define the JSON-RPC endpoint - JSONRPC(func() { - POST("/jsonrpc") // For HTTP and SSE - // OR - GET("/ws") // For WebSocket - }) - - // Define error mappings for all methods - Error("unauthorized", func() { - Description("Unauthorized access") - }) - - JSONRPC(func() { - Response("unauthorized", func() { - Code(-32000) // Map to JSON-RPC error code - }) - }) +Payload(func() { + ID("request_id", String) + Attribute("value", String) + Required("value") }) ``` -### Method Configuration - -Each method needs its own `JSONRPC()` block to be exposed: +If `request_id` is required, callers must send a request ID. If it is optional, +the generated client omits the JSON-RPC `id` when the field is empty and sends +a notification. The generated server sets the field from the incoming ID +before calling the service. -```go -Method("process", func() { - Description("Process data") - - Payload(func() { - Attribute("data", String, "Data to process") - Attribute("priority", Int, "Processing priority") - Required("data") - }) - - Result(func() { - Attribute("output", String, "Processed output") - Attribute("duration", Int, "Processing time in ms") - Required("output", "duration") - }) - - // Enable JSON-RPC for this method - JSONRPC(func() { - // Method-specific error mappings (optional) - Response("invalid_data", func() { - Code(-32002) - }) - }) -}) -``` +Unary result types may also declare an `ID` field. When the service returns a +non-empty result ID, the generated server uses it as the response ID. Otherwise +it uses the request ID. -### ID Field Mapping +## Server streaming with SSE -Control how JSON-RPC message IDs map to your payload and result types: +JSON-RPC supports server-to-client streams through Server-Sent Events (SSE). +Define a `StreamingResult` and select `ServerSentEvents` in the method-level +JSON-RPC block: ```go -Method("track", func() { - Payload(func() { - ID("request_id", String, "Tracking ID") // Maps to JSON-RPC request ID - Attribute("action", String) - Required("request_id", "action") - }) - - Result(func() { - ID("request_id", String, "Tracking ID") // Optional; if empty the - // response uses the request id - Attribute("status", String) - Required("request_id", "status") - }) - - JSONRPC(func() {}) +var Event = Type("Event", func() { + Attribute("message", String) + Required("message") }) -``` +var _ = Service("updates", func() { + JSONRPC(func() { + POST("/rpc") + }) -The `ID()` function marks which field receives the JSON-RPC message ID. Rules: - -1. ID fields must be String type -2. Result can only have an ID if Payload has one -3. For non-streaming methods, the response `id` defaults to the request `id`. - If the result ID is set, that value is used instead. -4. Missing ID at runtime means the message is a notification - -### ID Semantics - -How IDs behave across transports and shapes: - -- Design-time type - - `ID()` marks the field that carries the JSON-RPC ID; it must be `String` in - the design. - -- Runtime type - - JSON-RPC allows string or number IDs. Goa accepts either on the wire and - normalizes to string when assigning to your `ID()` fields. - -- HTTP (request/response) - - Client - - If the payload has an ID field and it is non-empty, the client sends it - as `id` (request). If empty (or nil pointer), the client omits `id` - (notification). - - If the payload has no ID field, the client generates a string `id` and - sends a request (never a notification). - - Server - - The response envelope `id` equals the result ID if set; otherwise it - equals the request `id`. The server does not inject the request `id` into - your result struct. - -- SSE (server streaming) - - `Send(ctx, event)`: emits a JSON-RPC notification (no `id`). - - `SendAndClose(ctx, result)`: sends a JSON-RPC response. The `id` equals the - result ID if set; otherwise the original request `id`. To avoid duplicate - fields, the framework clears the result ID field when it is used for the - envelope. - -- WebSocket (streaming) - - Server replies use the original request `id` automatically. Use - `SendNotification` for server-initiated messages (no `id`). - - Client generates a string `id` per request in bidirectional or recv-only - patterns. When receiving, if your result has an ID field and it is empty, - the client populates it from the envelope `id` for convenience. - -- When to use `ID()` in the DSL - - Non-streaming: put `ID()` in the payload to receive request IDs in your - handler; add it to the result only if you need to surface the ID in your - result type. - - Streaming (WebSocket bidirectional): include `ID()` in both streaming - payload and result to correlate messages at the type level. - - Notifications: omit `ID()` (no `id` is sent or expected). - -## Transport Options - -### HTTP: Request-Response - -Standard synchronous RPC over HTTP. Best for: -- Simple request-response patterns -- Stateless operations -- RESTful service migration - -```go -Service("api", func() { - JSONRPC(func() { - POST("/rpc") - }) - - Method("query", func() { - Payload(func() { - Attribute("sql", String) - Required("sql") - }) - Result(ArrayOf(map[string]any)) - JSONRPC(func() {}) - }) + Method("watch", func() { + Payload(func() { + Attribute("topic", String) + Required("topic") + }) + StreamingResult(Event) + JSONRPC(func() { + ServerSentEvents() + }) + }) }) ``` -**Client usage:** -```go -client := api.NewClient("http", "localhost:8080", http.DefaultClient, - goahttp.RequestEncoder, goahttp.ResponseDecoder, false) - -result, err := client.Query(ctx, &api.QueryPayload{SQL: "SELECT * FROM users"}) -``` - -**Wire format:** -```http -POST /rpc HTTP/1.1 -Content-Type: application/json - -{"jsonrpc":"2.0","method":"query","params":{"sql":"SELECT * FROM users"},"id":1} -``` - -**How it works internally:** - -- The generated server inspects the first byte of the body to route batch - (`[` starts a JSON array) vs single requests, then decodes a - `jsonrpc.RawRequest` and validates `jsonrpc:"2.0"`, `method`, and - `params`. -- Dispatch is by the `method` field to the corresponding generated handler - for your service method. The handler decodes the typed payload, invokes - your implementation, and encodes a typed JSON-RPC response via - `MakeSuccessResponse(id, result)`. -- If the incoming message has no `id` (a notification), the server does not - send a response, per the spec. -- Batch requests are decoded to `[]jsonrpc.RawRequest` and each entry is - processed independently; responses are streamed into a JSON array. - -### Server-Sent Events: Server Streaming - -Unidirectional streaming from server to client. Perfect for: -- Progress updates -- Live notifications -- Real-time feeds -- Long-running operations +The generated service method receives the same exact typed stream interface as +other Goa transports: ```go -Service("monitor", func() { - JSONRPC(func() { - POST("/events") // SSE uses POST for initial payload - }) - - Method("watch", func() { - Description("Watch system metrics") - - Payload(func() { - Attribute("metrics", ArrayOf(String), "Metrics to watch") - Required("metrics") - }) - - StreamingResult(func() { - Attribute("metric", String) - Attribute("value", Float64) - Attribute("timestamp", String, func() { - Format(FormatDateTime) - }) - Required("metric", "value", "timestamp") - }) - - JSONRPC(func() { - ServerSentEvents(func() { - SSEEventType("metric") // SSE event type field - }) - }) - }) -}) -``` - -**Server implementation:** - -```go -func (s *monitorSvc) Watch(ctx context.Context, p *monitor.WatchPayload, - stream monitor.WatchServerStream) error { - - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return nil - case <-ticker.C: - for _, metric := range p.Metrics { - err := stream.Send(ctx, &monitor.WatchResult{ - Metric: metric, - Value: getMetricValue(metric), - Timestamp: time.Now().Format(time.RFC3339), - }) - if err != nil { - return err - } - } - } - } +func (s *updatesService) Watch(ctx context.Context, p *updates.WatchPayload, stream updates.WatchServerStream) error { + if err := stream.Send(&updates.Event{Message: "ready"}); err != nil { + return err + } + return nil } ``` -**Client usage:** +The stream provides: -```go -httpClient := monitorjsonrpc.NewClient(/* ... */) -stream, err := httpClient.Watch(ctx, &monitor.WatchPayload{ - Metrics: []string{"cpu", "memory"}, -}) +- `Send(T) error` +- `SendWithContext(context.Context, T) error` +- `Close() error` +- `SetView(string)` when the streaming result has selectable views -for { - result, err := stream.Recv() - if err == io.EOF { - break - } - log.Printf("%s: %f", result.Metric, result.Value) -} -``` - -**How it works internally:** - -- SSE uses a regular HTTP POST to deliver the initial JSON-RPC request. The - generated handler decodes a `jsonrpc.RawRequest`, validates it, and - dispatches to the method-specific SSE handler. -- The SSE response is a long-lived HTTP response with - `Content-Type: text/event-stream`. The generated stream type writes events - using standard SSE framing (`id:`, `event:`, `data:`, blank line). -- The server stream interface exposes: - - `Send(ctx, event)`: writes a JSON-RPC notification as an SSE event - (no response expected). Use this for progress or updates. - - `SendAndClose(ctx, result)`: sends the final JSON-RPC response (with `id`) - and closes the stream. The response `id` is taken from the original - request `id`, or from a result `ID()` field if defined in the design. - - `SendError(ctx, id, err)`: writes a JSON-RPC error response. -- Notifications vs responses: - - Notifications omit `id` per JSON-RPC and are represented as SSE events - with the `data:` being the result body. - - Final responses include a JSON-RPC envelope; the SSE `id:` field mirrors - the JSON-RPC response `id` when an ID is present. -- Example on-the-wire SSE frame (simplified): - - ```text - event: metric - id: 7 - data: {"jsonrpc":"2.0","result":{"metric":"cpu","value":0.9},"id":"7"} - - ``` - -### WebSocket: Bidirectional Streaming - -Full-duplex, persistent connections for real-time communication. Ideal for: -- Chat applications -- Collaborative editing -- Gaming -- Live bidirectional data exchange - -```go -Service("chat", func() { - JSONRPC(func() { - GET("/ws") // WebSocket upgrade - }) - - // Client-to-server notifications - Method("send", func() { - StreamingPayload(func() { - Attribute("message", String) - Required("message") - }) - JSONRPC(func() {}) - }) - - // Server-to-client notifications - Method("broadcast", func() { - StreamingResult(func() { - Attribute("from", String) - Attribute("message", String) - Required("from", "message") - }) - JSONRPC(func() {}) - }) - - // Bidirectional request-response - Method("echo", func() { - StreamingPayload(func() { - ID("msg_id", String) - Attribute("text", String) - Required("msg_id", "text") - }) - StreamingResult(func() { - ID("msg_id", String) - Attribute("echo", String) - Required("msg_id", "echo") - }) - JSONRPC(func() {}) - }) -}) -``` +Each `Send` writes one SSE `notification` event containing a complete JSON-RPC +notification. When the method returns, the transport writes one terminal event +for a request with an ID: -**Server implementation:** - -```go -type chatSvc struct { - connections map[string]chat.BroadcastServerStream - mu sync.RWMutex -} +- success writes `result: null`; +- a returned error writes a JSON-RPC error. -func (s *chatSvc) HandleStream(ctx context.Context, stream chat.Stream) error { - // Register connection - connID := generateConnID() - s.mu.Lock() - s.connections[connID] = stream.(chat.BroadcastServerStream) - s.mu.Unlock() - - defer func() { - s.mu.Lock() - delete(s.connections, connID) - s.mu.Unlock() - stream.Close() - }() - - // Handle incoming messages - for { - _, err := stream.Recv(ctx) - if err != nil { - return err - } - // Messages are automatically dispatched to method handlers - } -} +A request without an ID receives streamed notifications but no terminal +response. The generated client returns notification values from `Recv`; after +a successful terminal response it returns `io.EOF`, and after an error response +it returns that error. -func (s *chatSvc) Send(ctx context.Context, p *chat.SendPayload) error { - // Broadcast to all connections - s.mu.RLock() - defer s.mu.RUnlock() - - for _, conn := range s.connections { - conn.SendNotification(ctx, &chat.BroadcastResult{ - From: "user", - Message: p.Message, - }) - } - return nil -} +JSON-RPC does not accept `StreamingPayload` or bidirectional streaming. Use +gRPC or an ordinary HTTP WebSocket method when the client must send a stream of +values. -func (s *chatSvc) Echo(ctx context.Context, p *chat.EchoPayload, - stream chat.EchoServerStream) error { - - return stream.SendResponse(ctx, &chat.EchoResult{ - MsgID: p.MsgID, - Echo: "Echo: " + p.Text, - }) -} -``` +### Last-Event-ID -**How it works internally:** - -- Connection lifecycle: - - The generated server upgrades the HTTP request to a WebSocket and - constructs a `Stream` implementation, then calls your - `HandleStream(ctx, stream)`. - - Your `HandleStream` should defer `stream.Close()` and typically loop on - `stream.Recv(ctx)`, which reads a JSON-RPC message and dispatches it to - the appropriate generated handler based on its `method`. -- Dispatch and method invocation: - - For non-streaming methods, `Recv` decodes the payload, invokes your - method, and sends the typed JSON-RPC success response via the stream. - - For streaming methods, `Recv` creates a method-specific stream wrapper - that implements your generated `XServerStream` interface and calls your - method implementation with it. -- Sending from your methods: - - In server or bidirectional streaming, your method receives a stream - wrapper providing: - - `SendNotification(ctx, result)`: sends a JSON-RPC notification (no id). - - `SendResponse(ctx, result)`: sends a JSON-RPC success response using the - original request `id`. You do not need to pass the id; the wrapper holds - it for you. - - `SendError(ctx, err)`: sends a JSON-RPC error response correlated to the - original request `id` when present. -- Notifications and responses: - - Messages without `id` are notifications. Use `SendNotification` for - server-initiated messages that should not expect a response. - - When replying to a client request that had an `id`, use `SendResponse` to - correlate via that `id` automatically. -- Error handling: - - Invalid messages (parse errors, missing method) trigger JSON-RPC error - responses when an `id` is present; otherwise they are ignored to keep the - connection alive. - - Unexpected WebSocket close codes abort the loop and close the connection. - -### Mixed Transports: Content Negotiation - -Combine HTTP and SSE in a single service using automatic content negotiation: +`SSERequestID` maps the incoming HTTP `Last-Event-ID` header to a string field +in the initial method payload: ```go -Service("hybrid", func() { - JSONRPC(func() { - POST("/api") - }) - - // Standard HTTP method - Method("status", func() { - Result(func() { - Attribute("healthy", Boolean) - Required("healthy") - }) - JSONRPC(func() {}) - }) - - // SSE streaming method - Method("monitor", func() { - StreamingResult(func() { - Attribute("event", String) - Attribute("data", Any) - }) - JSONRPC(func() { - ServerSentEvents(func() { - SSEEventType("update") - }) - }) - }) - - // Mixed results with content negotiation - Method("flexible", func() { - Payload(func() { - Attribute("resource", String) - Required("resource") - }) - - // Return simple result for HTTP - Result(func() { - Attribute("data", String) - Required("data") - }) - - // Return stream for SSE - StreamingResult(func() { - Attribute("chunk", String) - Attribute("progress", Int) - }) - - JSONRPC(func() { - ServerSentEvents(func() { - SSEEventType("progress") - }) - }) - }) +Payload(func() { + Attribute("last_event_id", String) }) -``` - -The server automatically routes based on the `Accept` header: -- `Accept: application/json` → HTTP handler → `Result` -- `Accept: text/event-stream` → SSE handler → `StreamingResult` - -Under the hood, the generated handler checks `Accept` at runtime and invokes -the SSE stream only when `text/event-stream` is requested and the method has -`StreamingResult` (including mixed-result shapes). Otherwise, the standard -HTTP request-response path is used. -## Advanced Features - -### Batch Processing - -JSON-RPC supports sending multiple requests in a single HTTP call: - -```json -[ - {"jsonrpc": "2.0", "method": "add", "params": {"a": 1, "b": 2}, "id": 1}, - {"jsonrpc": "2.0", "method": "multiply", "params": {"a": 3, "b": 4}, "id": 2}, - {"jsonrpc": "2.0", "method": "divide", "params": {"dividend": 10, "divisor": 2}, "id": 3} -] -``` - -The server processes each request independently and returns an array of responses: - -```json -[ - {"jsonrpc": "2.0", "result": 3, "id": 1}, - {"jsonrpc": "2.0", "result": 12, "id": 2}, - {"jsonrpc": "2.0", "result": 5, "id": 3} -] -``` - -Batch processing is automatic - no special configuration needed. - -### Error Handling - -Goa provides comprehensive error handling with standard JSON-RPC error codes: - -```go -Service("api", func() { - // Define service-level errors - Error("unauthorized", func() { - Description("User is not authorized") - }) - Error("rate_limited", func() { - Description("Too many requests") - }) - - JSONRPC(func() { - // Map errors to JSON-RPC codes - Response("unauthorized", func() { - Code(-32001) // Custom application code - }) - Response("rate_limited", func() { - Code(-32002) - }) - }) - - Method("secure", func() { - // ... method definition ... - Error("unauthorized") // Method can return this error - Error("invalid_token") // Method-specific error - - JSONRPC(func() { - Response("invalid_token", func() { - Code(-32003) - }) - }) - }) +JSONRPC(func() { + ServerSentEvents(func() { + SSERequestID("last_event_id") + }) }) ``` -Standard error codes: -- `-32700`: Parse error -- `-32600`: Invalid request -- `-32601`: Method not found -- `-32602`: Invalid params -- `-32603`: Internal error -- `-32000` to `-32099`: Reserved for implementation +The payload field stays optional unless the design marks it required. -### Streaming Patterns +JSON-RPC rejects a method that defines different `Result` and +`StreamingResult` types because the generated client cannot receive both from +one call. Define one method for the stream and another method for the final +resource. The two methods may share the same service and JSON-RPC path. -#### Client Streaming (WebSocket only) -```go -Method("upload", func() { - StreamingPayload(func() { - Attribute("chunk", Bytes) - Attribute("offset", Int64) - Required("chunk", "offset") - }) - Result(func() { - Attribute("size", Int64) - Attribute("checksum", String) - }) - JSONRPC(func() {}) -}) -``` +## Errors -#### Server Streaming (SSE or WebSocket) -```go -Method("download", func() { - Payload(func() { - Attribute("file", String) - Required("file") - }) - StreamingResult(func() { - Attribute("chunk", Bytes) - Attribute("offset", Int64) - Required("chunk", "offset") - }) - JSONRPC(func() { - ServerSentEvents(func() {}) // Or use WebSocket - }) -}) -``` +Map designed errors to JSON-RPC codes in the method design: -#### Bidirectional Streaming (WebSocket only) ```go -Method("transform", func() { - StreamingPayload(func() { - ID("seq", String) - Attribute("input", String) - Required("seq", "input") - }) - StreamingResult(func() { - ID("seq", String) - Attribute("output", String) - Required("seq", "output") - }) - JSONRPC(func() {}) +Method("divide", func() { + Error("division_by_zero") + JSONRPC(func() { + Response("division_by_zero", func() { + Code(-32001) + }) + }) }) ``` -### Mixed Results - -Support different response types based on content negotiation: - -```go -Method("report", func() { - Payload(func() { - Attribute("query", String) - Required("query") - }) - - // Simple result for synchronous HTTP - Result(func() { - Attribute("summary", String) - Attribute("count", Int) - Required("summary", "count") - }) - - // Streaming result for SSE - StreamingResult(func() { - Attribute("row", Map(String, Any)) - Attribute("progress", Float64) - }) - - JSONRPC(func() { - ServerSentEvents(func() { - SSEEventType("row") - }) - }) -}) -``` - -Implementation: - -```go -// Called for Accept: application/json -func (s *svc) Report(ctx context.Context, p *ReportPayload) (*ReportResult, error) { - summary, count := generateReport(p.Query) - return &ReportResult{Summary: summary, Count: count}, nil -} - -// Called for Accept: text/event-stream -func (s *svc) ReportStream(ctx context.Context, p *ReportPayload, - stream ReportServerStream) error { - - rows := queryRows(p.Query) - for i, row := range rows { - err := stream.Send(ctx, &ReportStreamingResult{ - Row: row, - Progress: float64(i) / float64(len(rows)), - }) - if err != nil { - return err - } - } - return nil -} -``` - -## Best Practices - -### 1. Service Design - -**DO:** -- Group related methods in the same service -- Use consistent naming conventions -- Define clear error codes and messages -- Document expected behavior - -**DON'T:** -- Mix WebSocket with HTTP endpoints in the same service -- Use deeply nested payload structures -- Rely on transport-specific features - -### 2. Error Handling - -**DO:** -- Map application errors to appropriate JSON-RPC codes -- Provide meaningful error messages -- Use standard codes when applicable -- Include error data when helpful +Goa also uses the standard JSON-RPC codes: -**DON'T:** -- Use reserved error code ranges -- Return stack traces in production -- Ignore validation errors +- `-32700` for malformed JSON; +- `-32600` for an invalid request; +- `-32601` for an unknown method; +- `-32602` for invalid parameters; and +- `-32603` for an unexpected service error. -### 3. Streaming +Service implementations return ordinary designed or unexpected errors. The +generated transport writes the JSON-RPC error and preserves the request ID. -**DO:** -- Use SSE for server-push scenarios -- Use WebSocket for bidirectional needs -- Implement proper cleanup in stream handlers -- Handle connection failures gracefully +## Batch requests -**DON'T:** -- Keep streams open indefinitely -- Send large payloads in single messages -- Ignore backpressure +Unary JSON-RPC methods accept a JSON array of requests and notifications. The +generated server dispatches each item and returns an array containing responses +only for items with an ID. An all-notification batch receives no JSON-RPC +response body. -### 4. Performance +SSE streams are opened by one request and are not batch operations. -**DO:** -- Use batch requests for multiple operations -- Implement connection pooling for clients -- Cache frequently accessed data -- Monitor message sizes +## Using JSON-RPC with other transports -**DON'T:** -- Create new connections per request -- Send unnecessary notifications -- Block stream handlers +A Goa method may also have ordinary `HTTP` or `GRPC` transport mappings. Each +generated transport implements the same service method contract. JSON-RPC +methods share their JSON-RPC route; ordinary HTTP routes and gRPC procedures +remain independent. -### Supporting Multiple Transports +## Generation -Expose the same service over multiple protocols: +Run Goa against the design package import path: -```go -Service("universal", func() { - // JSON-RPC configuration - JSONRPC(func() { - POST("/rpc") - }) - - Method("process", func() { - Payload(func() { - Attribute("data", String) - Required("data") - }) - Result(func() { - Attribute("output", String) - Required("output") - }) - - // Available via JSON-RPC - JSONRPC(func() {}) - - // Also available via HTTP REST - HTTP(func() { - POST("/process") - }) - - // And via gRPC - GRPC(func() {}) - }) -}) +```bash +goa gen example.com/project/design ``` -## Additional Resources - -- [JSON-RPC 2.0 Specification](https://www.jsonrpc.org/specification) -- [Goa Documentation](https://goa.design) -- [Example Services](https://github.com/goadesign/examples) -- [Integration Tests](../jsonrpc/integration_tests) - -## Summary - -Goa's JSON-RPC implementation provides: - -- **Type Safety**: Full compile-time type checking -- **Code Generation**: Automatic client/server code from DSL -- **Protocol Compliance**: Complete JSON-RPC 2.0 support -- **Transport Flexibility**: HTTP, SSE, and WebSocket options -- **Streaming Support**: Unidirectional and bidirectional patterns -- **Error Handling**: Comprehensive error mapping and codes -- **Content Negotiation**: Mixed results based on Accept headers -- **Batch Processing**: Automatic batch request handling +Do not edit generated files. Change the design or the owning generator and run +generation again. -The implementation seamlessly integrates with Goa's existing features while -maintaining clean separation of concerns and enabling powerful real-time -communication patterns. \ No newline at end of file +See [ARCHITECTURE.md](ARCHITECTURE.md) for generator ownership and the exact SSE +message lifecycle. diff --git a/jsonrpc/codegen/client.go b/jsonrpc/codegen/client.go index 2c17647ae0..685fd2dce2 100644 --- a/jsonrpc/codegen/client.go +++ b/jsonrpc/codegen/client.go @@ -1,3 +1,5 @@ +// This file writes JSON-RPC client calls, request encoders, and response +// decoders for each service. Each file imports only the types it uses. package codegen import ( @@ -5,121 +7,185 @@ import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// ClientFiles returns the generated JSON-RPC client files. -func ClientFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File { - jsvcs := data.Root.API.JSONRPC.Services - files := make([]*codegen.File, 0, len(jsvcs)*3) - for _, svc := range jsvcs { - files = append(files, clientFile(genpkg, svc, data)) - if f := websocketClientFile(genpkg, svc, data); f != nil { - files = append(files, f) - } - if f := sseClientFile(genpkg, svc, data); f != nil { - files = append(files, f) +type ( + // clientTemplateData stores the service values and Go names used to write + // one client package. + clientTemplateData struct { + httpcodegen.JSONRPCServiceSnapshot + // BufferPool is the byte buffer variable used while encoding requests. + BufferPool *codegen.NameDeclaration + } +) + +// clientFiles builds client, stream, and JSON conversion files from the +// services recorded before every generated Go name was assigned. +func clientFiles(services []*servicePlan) []*codegen.File { + files := make([]*codegen.File, 0, len(services)*3) + for _, planned := range services { + renderPlan := servicePlanForOutput(planned, true) + files = append(files, addFileImports(clientFile(renderPlan), planned.data)) + if f := sseClientFile(renderPlan); f != nil { + files = append(files, addFileImports(f, planned.data)) } } - for _, svc := range jsvcs { - f := httpcodegen.ClientEncodeDecodeFile(genpkg, svc, data) + for _, planned := range services { + f := planned.data.ClientCodecFile() if f == nil { continue } - var swapped int + sections := make([]*codegen.SectionTemplate, 0, len(f.SectionTemplates)) + var decoders int for _, s := range f.SectionTemplates { switch s.Name { case "source-header": codegen.AddImport(s, &codegen.ImportSpec{Path: "bufio"}) codegen.AddImport(s, &codegen.ImportSpec{Path: "bytes"}) + codegen.AddImport(s, &codegen.ImportSpec{Path: "errors"}) codegen.AddImport(s, &codegen.ImportSpec{Path: "sync"}) - codegen.AddImport(s, &codegen.ImportSpec{Path: "sync/atomic"}) codegen.AddImport(s, codegen.GoaImport("jsonrpc")) case "response-decoder": + endpoint := s.Data.(*httpcodegen.JSONRPCEndpointSnapshot) + if endpoint.SSE != nil { + continue + } s.Source = jsonrpcTemplates.Read(responseDecoderT, singleResponseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP) - swapped++ + s.FuncMap["buildResponseData"] = buildJSONRPCResponseData + for name, function := range viewedResultFuncs(planned) { + s.FuncMap[name] = function + } + decoders++ } s.Name = "jsonrpc-" + s.Name + sections = append(sections, s) + } + f.SectionTemplates = sections + viewed := clientViewedResultSections(planned) + if len(viewed) > 0 { + header := f.SectionTemplates[0] + codegen.AddImport(header, &codegen.ImportSpec{Path: "encoding/json"}) + codegen.AddImport(header, codegen.GoaImport("")) + codegen.AddImport(header, planned.data.ClientViewImport()) + f.SectionTemplates = append(f.SectionTemplates, &codegen.SectionTemplate{ + Name: "jsonrpc-viewed-result-body-decoder", + Source: jsonrpcTemplates.Read(viewedResultBodyDecodeT), + Data: planned.bodyDecoder, + }) + f.SectionTemplates = append(f.SectionTemplates, viewed...) } - // The HTTP client file emits exactly one response decoder per - // endpoint. Guard against the two generators drifting apart. - if n := len(data.Get(svc.Name()).Endpoints); swapped != n { - panic(fmt.Sprintf("jsonrpc: swapped %d response decoders for service %q, expected %d", swapped, svc.Name(), n)) + // Each method that returns one response needs exactly one decoder. + var expected int + for _, endpoint := range planned.data.Endpoints { + if endpoint.SSE == nil { + expected++ + } + } + if decoders != expected { + panic(fmt.Sprintf("jsonrpc: wrote %d response decoders for service %q, expected %d", decoders, planned.name, expected)) } - files = append(files, f) + files = append(files, addFileImports(f, planned.data)) } return files } -// clientFile returns the client HTTP transport file -func clientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) +// buildJSONRPCResponseData gives the shared response reader one copied JSON-RPC +// response together with the service and method names written in client errors. +func buildJSONRPCResponseData(data httpcodegen.JSONRPCResponseData, serviceName string, method httpcodegen.JSONRPCMethodData) map[string]any { + return map[string]any{ + "Data": data, + "ServiceName": serviceName, + "Method": method, + } +} + +// clientFile builds the JSON-RPC client methods for one service. +func clientFile(planned *servicePlan) *codegen.File { + data := planned.data + renderData := &clientTemplateData{ + JSONRPCServiceSnapshot: data, + BufferPool: planned.clientNames.bufferPool, + } svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, "jsonrpc", svcName, "client", "client.go") - title := fmt.Sprintf("%s client JSON-RPC transport", svc.Name()) - sections := []*codegen.SectionTemplate{ - codegen.Header(title, "client", []*codegen.ImportSpec{ - {Path: "bufio"}, - {Path: "bytes"}, - {Path: "context"}, - {Path: "fmt"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "strconv"}, - {Path: "strings"}, - {Path: "sync"}, - {Path: "sync/atomic"}, - {Path: "time"}, - {Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaImport("jsonrpc"), - codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, - }), + title := fmt.Sprintf("%s client JSON-RPC transport", planned.name) + imports := []*codegen.ImportSpec{ + {Path: "bufio"}, + {Path: "bytes"}, + {Path: "context"}, + {Path: "encoding/json"}, + {Path: "errors"}, + {Path: "fmt"}, + {Path: "io"}, + {Path: "net/http"}, + {Path: "strconv"}, + {Path: "strings"}, + {Path: "sync"}, + codegen.GoaImport(""), + codegen.GoaImport("jsonrpc"), + codegen.GoaNamedImport("http", "goahttp"), + data.ClientServiceImport(), } + sections := make([]*codegen.SectionTemplate, 0, 3+len(planned.endpoints)) + sections = append(sections, codegen.Header(title, "client", imports)) sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-client-struct", Source: jsonrpcTemplates.Read(clientStructT), - Data: data, + Data: renderData, FuncMap: map[string]any{ - "hasWebSocket": httpcodegen.HasWebSocket, - "hasSSE": httpcodegen.HasSSE, - "isSSEEndpoint": httpcodegen.IsSSEEndpoint, + "hasSSE": hasJSONRPCSSE, + "isSSEEndpoint": isJSONRPCSSEEndpoint, }, }) sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-client-init", Source: jsonrpcTemplates.Read(clientInitT), - Data: data, + Data: renderData, FuncMap: map[string]any{ - "hasWebSocket": httpcodegen.HasWebSocket, - "hasSSE": httpcodegen.HasSSE, - "isSSEEndpoint": httpcodegen.IsSSEEndpoint, + "hasSSE": hasJSONRPCSSE, + "isSSEEndpoint": isJSONRPCSSEEndpoint, }, }) - for _, e := range data.Endpoints { + funcs := viewedResultFuncs(planned) + for _, e := range planned.endpoints { sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-client-endpoint-init", Source: jsonrpcTemplates.Read(clientEndpointInitT), - Data: e, + Data: &e.JSONRPCEndpointSnapshot, FuncMap: map[string]any{ - "isWebSocketEndpoint": httpcodegen.IsWebSocketEndpoint, - "isSSEEndpoint": httpcodegen.IsSSEEndpoint, + "isSSEEndpoint": isJSONRPCSSEEndpoint, + "viewedDecodeName": funcs["viewedDecodeName"], }, }) } - if httpcodegen.HasWebSocket(data) { - sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-client-websocket-conn", - Source: jsonrpcTemplates.Read(websocketClientConnT), - Data: data, - }) + return &codegen.File{Path: path, SectionTemplates: sections} +} + +// hasJSONRPCSSE reports whether service has a method that sends server-sent +// events. Generated clients include stream fields only when one is needed. +func hasJSONRPCSSE(data any) bool { + service := jsonRPCClientService(data) + for _, endpoint := range service.Endpoints { + if endpoint.SSE != nil { + return true + } } + return false +} - return &codegen.File{Path: path, SectionTemplates: sections} +// jsonRPCClientService returns the copied service values used to write a +// generated client. +func jsonRPCClientService(data any) httpcodegen.JSONRPCServiceSnapshot { + switch value := data.(type) { + case httpcodegen.JSONRPCServiceSnapshot: + return value + case *clientTemplateData: + return value.JSONRPCServiceSnapshot + default: + panic(fmt.Sprintf("JSON-RPC client received data of type %T", data)) + } } diff --git a/jsonrpc/codegen/example_server.go b/jsonrpc/codegen/example_server.go deleted file mode 100644 index c149a47c9b..0000000000 --- a/jsonrpc/codegen/example_server.go +++ /dev/null @@ -1,85 +0,0 @@ -package codegen - -import ( - "path" - "path/filepath" - - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/expr" - httpcodegen "goa.design/goa/v3/http/codegen" -) - -// ExampleServerFiles returns example JSON-RPC server implementation. -func ExampleServerFiles(genpkg string, data *httpcodegen.ServicesData, files []*codegen.File) []*codegen.File { - var fw []*codegen.File - for _, svr := range data.Root.API.Servers { - if m := exampleServer(genpkg, data, svr, files); m != nil { - fw = append(fw, m) - } - } - return fw -} - -func exampleServer(genpkg string, data *httpcodegen.ServicesData, svr *expr.ServerExpr, files []*codegen.File) *codegen.File { - svrdata := example.Servers.Get(svr, data.Root) - httppath := filepath.Join("cmd", svrdata.Dir, "http.go") - - // Retrieve existing HTTP server file or create a new one - var file *codegen.File - var hasHTTP bool - for _, f := range files { - if f.Path == httppath { - file = f - hasHTTP = true - break - } - } - if file == nil { - file = httpcodegen.ExampleServer(genpkg, data.Root, svr, data) - } - - // Add JSON-RPC imports to the HTTP server file - header := file.SectionTemplates[0] - scope := codegen.NewNameScope() - for _, svc := range data.Root.API.JSONRPC.Services { - sd := data.Get(svc.Name()) - svcName := sd.Service.PathName - codegen.AddImport(header, &codegen.ImportSpec{ - Path: path.Join(genpkg, svcName), - Name: scope.Unique(sd.Service.PkgName), - }) - codegen.AddImport(header, &codegen.ImportSpec{ - Path: path.Join(genpkg, "jsonrpc", svcName, "server"), - Name: scope.Unique(sd.Service.PkgName + "jssvr"), - }) - } - - // Add JSON-RPC to the HTTP server file - var svcdata []*httpcodegen.ServiceData - for _, svc := range svr.Services { - if d := data.Get(svc); d != nil { - svcdata = append(svcdata, d) - } - } - for _, s := range file.SectionTemplates { - switch s.Name { - case "server-http-start": - // Only set the JSON-RPC services if not already populated. - data := s.Data.(map[string]any) - if existing, _ := data["JSONRPCServices"].([]*httpcodegen.ServiceData); len(existing) == 0 { - data["JSONRPCServices"] = svcdata - } - case "server-http-init", "server-http-end": - updateData(s, svcdata, hasHTTP) - } - } - return file -} - -func updateData(s *codegen.SectionTemplate, svcdata []*httpcodegen.ServiceData, hasHTTP bool) { - s.Data.(map[string]any)["JSONRPCServices"] = svcdata - if !hasHTTP { - delete(s.Data.(map[string]any), "Services") - } -} diff --git a/jsonrpc/codegen/idempotency_test.go b/jsonrpc/codegen/idempotency_test.go index 89444dab4e..2a987672f3 100644 --- a/jsonrpc/codegen/idempotency_test.go +++ b/jsonrpc/codegen/idempotency_test.go @@ -31,8 +31,8 @@ func TestIdempotentJSONRPCEndpointCodegen(t *testing.T) { }) }) }) - services := CreateJSONRPCServices(root) - clientFiles := ClientFiles("", services) + plan := CreateJSONRPCPlan(root) + clientFiles := plan.ClientFiles() require.NotEmpty(t, clientFiles) var clientCode string diff --git a/jsonrpc/codegen/kitchen_sink_test.go b/jsonrpc/codegen/kitchen_sink_test.go index b423926d31..46ebcc200d 100644 --- a/jsonrpc/codegen/kitchen_sink_test.go +++ b/jsonrpc/codegen/kitchen_sink_test.go @@ -1,40 +1,68 @@ +// This file compiles a representative JSON-RPC design and compares every +// generated package with its checked-in golden contract. package codegen_test import ( + "context" "io/fs" "os" + "os/exec" "path/filepath" "sort" "strings" "testing" + "time" "github.com/stretchr/testify/require" goacodegen "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/generator" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" "goa.design/goa/v3/jsonrpc/codegen/testdata" ) // TestJSONRPCKitchenSink pins every file the transport and example generators // produce for a design covering the full JSON-RPC surface (plain methods, -// required/optional IDs, custom errors, WebSocket, SSE, mixed HTTP+JSON-RPC +// required and optional IDs, custom errors, SSE, and mixed HTTP and JSON-RPC // transports). Each rendered file is compared against a golden copy under // testdata/golden/kitchen_sink and the set of generated paths is compared // against a manifest so files that appear or disappear fail the test. func TestJSONRPCKitchenSink(t *testing.T) { root := expr.RunDSL(t, testdata.JSONRPCKitchenSinkDSL) - // The test invokes the generator functions directly so it must apply the - // design normalization generator.Generate runs before them. - goacodegen.NormalizeRoot(root) roots := []eval.Root{root} - - tfiles, err := generator.Transport("kitchensink", roots) + generation, err := goacodegen.NewGeneration("generated.local/gen", roots) + require.NoError(t, err) + examples := expr.NewExampleGenerator(root.API.RandomizerFactory) + servicePlan, err := service.NewPlan(root, generation, examples) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + jsonHTTPPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + jsonPlans, err := jsonrpccodegen.NewPlans(generation, jsonrpccodegen.PlanInput{ + Root: root, Service: servicePlan, HTTP: jsonHTTPPlans[0], ApplicationHTTP: httpPlans[0], + }) require.NoError(t, err) - efiles, err := generator.Example("kitchensink", roots) + examplePlan, err := example.NewPlan(generation, servicePlan) require.NoError(t, err) + httpExamples, err := httpcodegen.NewExamplePlan(httpPlans[0], examplePlan) + require.NoError(t, err) + jsonExamples, err := jsonrpccodegen.NewExamplePlan(jsonPlans[0], examplePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, jsonHTTPPlans[0].Link()) + require.NoError(t, jsonPlans[0].Link()) + tfiles := kitchenSinkTransportFiles(httpPlans[0], jsonPlans[0]) + rootData, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + efiles := kitchenSinkExampleFiles(rootData, servicePlan, httpExamples, jsonExamples) tmp := t.TempDir() for _, f := range append(tfiles, efiles...) { @@ -66,4 +94,80 @@ func TestJSONRPCKitchenSink(t *testing.T) { require.NoError(t, err) testutil.CompareOrUpdateGolden(t, string(content), filepath.Join(goldenDir, rel+".golden")) } + feedCodec, err := os.ReadFile(filepath.Join(tmp, "gen", "jsonrpc", "feed", "client", "encode_decode.go")) + require.NoError(t, err) + require.NotContains(t, string(feedCodec), "DecodeWatchResponse") + require.Contains(t, string(feedCodec), "DecodeSnapshotResponse") + compileKitchenSink(t, servicePlan, tfiles) +} + +// kitchenSinkTransportFiles assembles every transport file through the public +// subsystem APIs exercised by the golden fixture. +func kitchenSinkTransportFiles(httpPlan *httpcodegen.Plan, jsonPlan *jsonrpccodegen.Plan) []*goacodegen.File { + files := httpPlan.ServerFiles() + files = append(files, httpPlan.ClientFiles()...) + files = append(files, httpPlan.ServerTypeFiles()...) + files = append(files, httpPlan.ClientTypeFiles()...) + files = append(files, httpPlan.PathFiles()...) + files = append(files, httpPlan.ClientCLIFiles()...) + + files = append(files, jsonPlan.ServerFiles()...) + files = append(files, jsonPlan.ClientFiles()...) + files = append(files, jsonPlan.ServerTypeFiles()...) + files = append(files, jsonPlan.ClientTypeFiles()...) + files = append(files, jsonPlan.PathFiles()...) + return append(files, jsonPlan.ClientCLIFiles()...) +} + +// kitchenSinkExampleFiles assembles example service and transport files +// through their public subsystem APIs. +func kitchenSinkExampleFiles(root *example.Root, plan *service.Plan, httpPlan *httpcodegen.ExamplePlan, jsonPlan *jsonrpccodegen.ExamplePlan) []*goacodegen.File { + services := plan.Services() + files := service.ExampleServiceFiles(plan) + files = append(files, service.ExampleInterceptorsFiles(plan)...) + files = append(files, example.ServerFiles(root, services)...) + files = append(files, example.CLIFiles(root)...) + + files = append(files, httpPlan.CLIFiles()...) + files = append(files, jsonPlan.ServerFiles()...) + files = append(files, jsonPlan.CLIFiles()...) + return files +} + +// compileKitchenSink renders the service and transport packages together so +// every generated conversion must use a concrete value accepted by the +// service contract it returns. +func compileKitchenSink(t *testing.T, servicePlan *service.Plan, transportFiles []*goacodegen.File) { + t.Helper() + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + dir := t.TempDir() + for _, file := range append(serviceFiles, transportFiles...) { + _, err := file.Render(dir) + require.NoError(t, err) + } + + goaDir := goaModuleDirectory(t) + module := "module generated.local\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaDir) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) +} + +// goaModuleDirectory returns the local Goa checkout used to build this test. +func goaModuleDirectory(t *testing.T) string { + t.Helper() + cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "goa.design/goa/v3") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + dir := strings.TrimSpace(string(output)) + require.NotEmpty(t, dir) + return dir } diff --git a/jsonrpc/codegen/package_import_alias_test.go b/jsonrpc/codegen/package_import_alias_test.go new file mode 100644 index 0000000000..77e9ec1f1f --- /dev/null +++ b/jsonrpc/codegen/package_import_alias_test.go @@ -0,0 +1,52 @@ +// This file verifies that transport packages choose import names in their own +// Go package instead of sharing names across the entire generation. +package codegen + +import ( + "path" + "testing" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +// TestTransportCLIPackagesChooseClientAliasesIndependently proves that the +// HTTP and JSON-RPC command packages may both use the natural client alias. +func TestTransportCLIPackagesChooseClientAliasesIndependently(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.HTTP(func() { dsl.POST("/add") }) + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + jsonHTTPPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + _, err = NewPlans(generation, PlanInput{ + Root: root, + Service: servicePlan, + HTTP: jsonHTTPPlans[0], + ApplicationHTTP: httpPlans[0], + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + serverName := goacodegen.SnakeCase(goacodegen.Goify(root.API.Servers[0].Name, true)) + httpCLI := generation.Package(path.Join(generation.GenPkg(), "http", "cli", serverName)) + jsonrpcCLI := generation.Package(path.Join(generation.GenPkg(), "jsonrpc", "cli", serverName)) + require.Equal(t, "calcc", httpCLI.ImportName(path.Join(generation.GenPkg(), "http", "calc", "client"))) + require.Equal(t, "calcc", jsonrpcCLI.ImportName(path.Join(generation.GenPkg(), "jsonrpc", "calc", "client"))) +} diff --git a/jsonrpc/codegen/plan.go b/jsonrpc/codegen/plan.go new file mode 100644 index 0000000000..989ee7f565 --- /dev/null +++ b/jsonrpc/codegen/plan.go @@ -0,0 +1,641 @@ +// This file prepares JSON-RPC files in two calls. NewPlans receives every +// design and requests all Go names. After Goa assigns those names and builds +// the service and HTTP values, Link creates the generated files. +package codegen + +import ( + "fmt" + "path" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +type ( + // PlanInput supplies one design and the copied values used to generate its + // service, JSON requests, and JSON responses. + PlanInput struct { + // Root is the design that declares the JSON-RPC services. + Root *expr.RootExpr + // Service provides the generated service types and method definitions. + Service *service.Plan + // HTTP provides the JSON request and response types used inside JSON-RPC messages. + HTTP *httpcodegen.Plan + // ApplicationHTTP is the ordinary HTTP plan whose runnable server is + // combined with JSON-RPC. It is nil when Root has no ordinary HTTP services. + ApplicationHTTP *httpcodegen.Plan + } + + // Plan stores the JSON-RPC function names chosen by NewPlans and the files + // created by Link. Goa creates one Plan for each design. + Plan struct { + generation *codegen.Generation + root *expr.RootExpr + service *service.Plan + http *httpcodegen.Plan + applicationHTTP *httpcodegen.Plan + services []*servicePlan + servicesByExpr map[*expr.HTTPServiceExpr]*servicePlan + server []*codegen.File + client []*codegen.File + linked bool + } + + // ExamplePlan builds runnable JSON-RPC programs from server data and + // generated services that came from the same design. + ExamplePlan struct { + transport *Plan + http *httpcodegen.ExamplePlan + } + + // servicePlan stores one service's generated package path, function names, + // and HTTP request and response data. + servicePlan struct { + data httpcodegen.JSONRPCServiceSnapshot + api string + name string + pathName string + endpoints []*endpointPlan + helpers map[string]*viewedHelperDeclarations + clientNames jsonRPCClientNames + serverNames jsonRPCServerNames + bodyDecoder *codegen.NameDeclaration + hasHTTP bool + hasSSE bool + } + + // endpointPlan contains the HTTP request, HTTP response, and JSON-RPC result + // values for one service method. + endpointPlan struct { + httpcodegen.JSONRPCEndpointSnapshot + viewed *viewedRepresentation + } + + // jsonRPCClientNames stores the Go names written once for one client. + jsonRPCClientNames struct { + bufferPool *codegen.NameDeclaration + } + + // jsonRPCServerNames stores the Go names written once for one server. + jsonRPCServerNames struct { + batchWriter *codegen.NameDeclaration + encodeError *codegen.NameDeclaration + sseStream *codegen.NameDeclaration + sseBuffer *codegen.NameDeclaration + } + + // viewedRepresentation lists the JSON body type and constructor used for + // each view that a method may return. + viewedRepresentation struct { + variable bool + fixedView string + branches []viewBranch + decode *codegen.NameDeclaration + encode *codegen.NameDeclaration + streamEncode *codegen.NameDeclaration + writeMetadata *codegen.NameDeclaration + viewedResult httpcodegen.JSONRPCViewedResultData + servicePkg string + resultRef string + } + + // viewBranch stores the mapped service field, JSON body types, and client + // constructor for one view. + viewBranch struct { + view string + resultAttr string + serverBody *httpcodegen.JSONRPCBodyData + clientBody *httpcodegen.JSONRPCBodyData + resultInit httpcodegen.InitData + headers []httpcodegen.JSONRPCHeaderData + cookies []httpcodegen.JSONRPCCookieData + } + + // viewedHelperDeclarations stores the client decoder and server encoder names + // written for one method result. + viewedHelperDeclarations struct { + decode *codegen.NameDeclaration + encode *codegen.NameDeclaration + streamEncode *codegen.NameDeclaration + writeMetadata *codegen.NameDeclaration + } + + // jsonRPCNameOrder gives the same Go names the same order on every run. + jsonRPCNameOrder struct { + api string + service string + method string + role uint8 + } +) + +const ( + viewedBodyDecoderRole uint8 = iota + 1 + viewedResultDecoderRole + viewedResultEncoderRole + viewedStreamEncoderRole + viewedMetadataWriterRole + jsonRPCBufferPoolRole + jsonRPCBatchWriterRole + jsonRPCEncodeErrorRole + jsonRPCSSEStreamRole + jsonRPCSSEBufferRole +) + +// NewPlans checks that inputs contain every design with JSON-RPC services once, +// then creates one Plan for each input. It requests every helper name before +// Goa chooses unique Go names, so generated definitions and calls agree. +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + if generation == nil { + return nil, fmt.Errorf("JSON-RPC plans require a generation") + } + if generation.Frozen() { + return nil, fmt.Errorf("JSON-RPC plans must be collected before generation freeze") + } + if err := validatePlanInputs(generation, inputs); err != nil { + return nil, err + } + plans := make([]*Plan, len(inputs)) + for index, input := range inputs { + plan := &Plan{ + generation: generation, + root: input.Root, + service: input.Service, + http: input.HTTP, + applicationHTTP: input.ApplicationHTTP, + servicesByExpr: make(map[*expr.HTTPServiceExpr]*servicePlan), + } + for _, transport := range input.Root.API.JSONRPC.Services { + planned, err := collectServicePlan(generation, input, transport) + if err != nil { + return nil, err + } + plan.services = append(plan.services, planned) + plan.servicesByExpr[transport] = planned + } + sort.Slice(plan.services, func(i, j int) bool { + return plan.services[i].name < plan.services[j].name + }) + plans[index] = plan + } + if err := planImports(generation, inputs); err != nil { + return nil, err + } + return plans, nil +} + +// NewExamplePlan returns an example renderer only when examples contains the +// server data copied from transport's service design. +func NewExamplePlan(transport *Plan, examples *example.Plan) (*ExamplePlan, error) { + if _, ok := examples.Root(transport.service); !ok { + return nil, fmt.Errorf("JSON-RPC examples require server data created from the same service design") + } + httpPlan, err := httpcodegen.NewExamplePlan(transport.http, examples) + if err != nil { + return nil, err + } + return &ExamplePlan{transport: transport, http: httpPlan}, nil +} + +// Root returns the design used to create p. +func (p *Plan) Root() *expr.RootExpr { + return p.root +} + +// Service returns the finalized JSON-RPC data for the exact service used to +// build this plan. Callers must call Link before reading the service data. +func (p *Plan) Service(service *expr.HTTPServiceExpr) (httpcodegen.JSONRPCServiceSnapshot, bool) { + p.requireLinked() + planned, ok := p.servicesByExpr[service] + if !ok { + return httpcodegen.JSONRPCServiceSnapshot{}, false + } + return p.http.JSONRPCService(planned.name) +} + +// Link reads the completed service and HTTP plans and builds every JSON-RPC +// file. The caller must first ask Goa to choose unique Go names and then link +// both input plans so all JSON body types and constructors are available. +func (p *Plan) Link() error { + if !p.generation.Frozen() { + return fmt.Errorf("JSON-RPC plan cannot link before generation freeze") + } + if p.linked { + return fmt.Errorf("JSON-RPC plan is already linked") + } + for _, planned := range p.services { + data, ok := p.http.JSONRPCService(planned.name) + if !ok { + return fmt.Errorf("HTTP plan has no data for JSON-RPC service %q", planned.name) + } + planned.data = data + planned.pathName = data.Service.PathName + for _, endpoint := range data.Endpoints { + helper := planned.helpers[endpoint.Method.Name] + viewed, hasViewedResult := p.http.ViewedResult(planned.name, endpoint.Method.Name) + plannedEndpoint := &endpointPlan{ + JSONRPCEndpointSnapshot: endpoint, + viewed: planViewedRepresentation(&endpoint, viewed, hasViewedResult, helper), + } + planned.endpoints = append(planned.endpoints, plannedEndpoint) + if endpoint.SSE != nil { + planned.hasSSE = true + } else { + planned.hasHTTP = true + } + } + } + p.server = serverFiles(p.services) + p.client = clientFiles(p.services) + p.linked = true + return nil +} + +// ServerFiles returns the JSON-RPC server files built by Link. +func (p *Plan) ServerFiles() []*codegen.File { + p.requireLinked() + return p.server +} + +// ClientFiles returns the JSON-RPC client files built by Link. +func (p *Plan) ClientFiles() []*codegen.File { + p.requireLinked() + return p.client +} + +// ServerTypeFiles returns the server JSON body files supplied by the HTTP plan. +func (p *Plan) ServerTypeFiles() []*codegen.File { + p.requireLinked() + return p.http.ServerTypeFiles() +} + +// ClientTypeFiles returns the client JSON body files supplied by the HTTP plan. +func (p *Plan) ClientTypeFiles() []*codegen.File { + p.requireLinked() + return p.http.ClientTypeFiles() +} + +// PathFiles returns the URL path helper files supplied by the HTTP plan. +func (p *Plan) PathFiles() []*codegen.File { + p.requireLinked() + return p.http.PathFiles() +} + +// ClientCLIFiles returns the command-line client files supplied by the HTTP plan. +func (p *Plan) ClientCLIFiles() []*codegen.File { + p.requireLinked() + return p.http.ClientCLIFiles() +} + +// ServerFiles builds runnable servers that mount the saved ordinary HTTP and +// JSON-RPC services for each copied server. +func (p *ExamplePlan) ServerFiles() []*codegen.File { + p.transport.requireLinked() + return p.http.CombinedServerFiles(p.transport.applicationHTTP) +} + +// CLIFiles builds runnable JSON-RPC clients for each copied server. +func (p *ExamplePlan) CLIFiles() []*codegen.File { + p.transport.requireLinked() + return p.http.CLIFiles() +} + +// planViewedRepresentation copies each allowed result view and its JSON body +// type into the values used to write JSON-RPC files. A method fixed to one +// view stores one branch. A method that chooses a view for each result stores +// every branch and includes the selected view name in each response. +func planViewedRepresentation(endpoint *httpcodegen.JSONRPCEndpointSnapshot, viewed httpcodegen.ViewedResultSnapshot, hasViewedResult bool, helpers *viewedHelperDeclarations) *viewedRepresentation { + if !hasViewedResult { + return nil + } + if helpers == nil { + panic(fmt.Sprintf("JSON-RPC viewed endpoint %q has no helper names declared by NewPlans", endpoint.Method.Name)) + } + representation := &viewedRepresentation{ + variable: viewed.Variable, + fixedView: viewed.FixedView, + decode: helpers.decode, + encode: helpers.encode, + streamEncode: helpers.streamEncode, + writeMetadata: helpers.writeMetadata, + viewedResult: viewed.Service, + servicePkg: endpoint.ServicePkgName, + resultRef: endpoint.Result.Ref, + } + for _, branch := range viewed.Representations { + representation.branches = append(representation.branches, viewBranch{ + view: branch.View, + resultAttr: branch.ResultAttr, + serverBody: branch.ServerBody, + clientBody: branch.ClientBody, + resultInit: branch.ResultInit, + headers: branch.Headers, + cookies: branch.Cookies, + }) + } + return representation +} + +// servicePlanForOutput copies the service package qualifiers written by one +// JSON-RPC client or server package. The two packages reserve imports +// independently, so a standard-library name used only by the server may suffix +// the generated service import only on that side. +func servicePlanForOutput(planned *servicePlan, client bool) *servicePlan { + copy := *planned + copy.data = planned.data + serviceImport := planned.data.ServerServiceImport() + if client { + serviceImport = planned.data.ClientServiceImport() + } + copy.data.Service.PkgName = serviceImport.Name + copy.data.Endpoints = make([]httpcodegen.JSONRPCEndpointSnapshot, len(planned.data.Endpoints)) + copy.endpoints = make([]*endpointPlan, len(planned.endpoints)) + for index, endpoint := range planned.endpoints { + endpointCopy := *endpoint + endpointCopy.ServicePkgName = serviceImport.Name + if endpoint.viewed != nil { + viewedCopy := *endpoint.viewed + viewedCopy.servicePkg = serviceImport.Name + endpointCopy.viewed = &viewedCopy + } + copy.endpoints[index] = &endpointCopy + copy.data.Endpoints[index] = endpointCopy.JSONRPCEndpointSnapshot + } + return © +} + +// requireLinked stops callers from reading files before Link has built them. +func (p *Plan) requireLinked() { + if !p.linked { + panic("JSON-RPC files requested before Plan.Link") + } +} + +// planImports records every import name written directly into JSON-RPC files. +func planImports(generation *codegen.Generation, inputs []PlanInput) error { + clientImports := []*codegen.ImportSpec{ + codegen.SimpleImport("bufio"), + codegen.SimpleImport("sync"), + codegen.GoaImport("jsonrpc"), + } + serverImports := []*codegen.ImportSpec{ + codegen.SimpleImport("bytes"), + codegen.SimpleImport("mime"), + codegen.GoaImport(""), + codegen.GoaImport("jsonrpc"), + } + for _, input := range inputs { + for _, transport := range input.Root.API.JSONRPC.Services { + serviceImport, _, err := input.Service.ServicePackageImports(transport.ServiceExpr) + if err != nil { + return err + } + pathName := path.Base(serviceImport.Path) + for index, outputPackage := range []*codegen.GeneratedPackage{ + generation.Package(path.Join(generation.GenPkg(), "jsonrpc", pathName, "client")), + generation.Package(path.Join(generation.GenPkg(), "jsonrpc", pathName, "server")), + } { + imports := clientImports + if index == 1 { + imports = serverImports + } + for _, spec := range imports { + if err := outputPackage.RequireImport(spec); err != nil { + return err + } + } + if err := outputPackage.ReserveGeneratedImport(serviceImport); err != nil { + return err + } + } + } + } + return nil +} + +// validatePlanInputs checks every root and plan before NewPlans submits an +// import or generated helper name. This keeps a rejected input from changing +// names chosen for later generators in the same run. +func validatePlanInputs(generation *codegen.Generation, inputs []PlanInput) error { + roots := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + root, ok := candidate.(*expr.RootExpr) + if ok && len(root.API.JSONRPC.Services) > 0 { + roots[root] = struct{}{} + } + } + seen := make(map[*expr.RootExpr]struct{}, len(inputs)) + for _, input := range inputs { + if input.Root == nil || !generation.HasRoot(input.Root) { + return fmt.Errorf("JSON-RPC plan requires a root in this generation") + } + if _, ok := roots[input.Root]; !ok { + return fmt.Errorf("root does not declare JSON-RPC services") + } + if _, ok := seen[input.Root]; ok { + return fmt.Errorf("JSON-RPC root is planned more than once: %s", rootServiceName(input.Root)) + } + seen[input.Root] = struct{}{} + if input.Service == nil { + return fmt.Errorf("JSON-RPC root %s requires a service plan", rootServiceName(input.Root)) + } + if input.Service.Root() != input.Root { + return fmt.Errorf("JSON-RPC service plan does not belong to root %s", rootServiceName(input.Root)) + } + if input.HTTP == nil { + return fmt.Errorf("JSON-RPC root %s requires an HTTP plan for its JSON request and response types", rootServiceName(input.Root)) + } + if !input.HTTP.MatchesJSONRPC(input.Root, input.Service) { + return fmt.Errorf("JSON-RPC HTTP plan does not belong to root %s and its service plan", rootServiceName(input.Root)) + } + hasHTTP := len(input.Root.API.HTTP.Services) > 0 + if hasHTTP && input.ApplicationHTTP == nil { + return fmt.Errorf("JSON-RPC root %s requires its application HTTP plan", rootServiceName(input.Root)) + } + if !hasHTTP && input.ApplicationHTTP != nil { + return fmt.Errorf("JSON-RPC root %s has no ordinary HTTP services", rootServiceName(input.Root)) + } + if input.ApplicationHTTP != nil && !input.ApplicationHTTP.MatchesHTTP(input.Root, input.Service) { + return fmt.Errorf("application HTTP plan does not belong to root %s and its service plan", rootServiceName(input.Root)) + } + } + if len(inputs) != len(roots) { + return fmt.Errorf("JSON-RPC planning requires all %d JSON-RPC roots, got %d", len(roots), len(inputs)) + } + return nil +} + +// rootServiceName returns the name shown in errors after validation has proved +// that root declares a JSON-RPC service. +func rootServiceName(root *expr.RootExpr) string { + return root.Services[0].Name +} + +// isJSONRPCSSEEndpoint reports whether the supplied method writes server-sent +// events. +func isJSONRPCSSEEndpoint(data any) bool { + return jsonRPCEndpoint(data).SSE != nil +} + +// jsonRPCEndpoint returns the method values used to write a generated file. +func jsonRPCEndpoint(data any) *httpcodegen.JSONRPCEndpointSnapshot { + switch endpoint := data.(type) { + case httpcodegen.JSONRPCEndpointSnapshot: + return &endpoint + case *httpcodegen.JSONRPCEndpointSnapshot: + return endpoint + case *endpointPlan: + return &endpoint.JSONRPCEndpointSnapshot + default: + panic(fmt.Sprintf("JSON-RPC template received endpoint data of type %T", data)) + } +} + +// collectServicePlan stores the designed service name and generated package +// path, then requests client decoder and server encoder names for every method +// that returns a result view. Link later adds the HTTP endpoint data used to +// build that service's files. +func collectServicePlan(generation *codegen.Generation, input PlanInput, transport *expr.HTTPServiceExpr) (*servicePlan, error) { + serviceImport, _, err := input.Service.ServicePackageImports(transport.ServiceExpr) + if err != nil { + return nil, err + } + pathName := path.Base(serviceImport.Path) + clientPath := path.Join(generation.GenPkg(), "jsonrpc", pathName, "client") + serverPath := path.Join(generation.GenPkg(), "jsonrpc", pathName, "server") + client, err := generation.ClaimPackage(clientPath) + if err != nil { + return nil, err + } + server, err := generation.ClaimPackage(serverPath) + if err != nil { + return nil, err + } + planned := &servicePlan{ + api: input.Root.API.Name, + name: transport.Name(), + pathName: pathName, + helpers: make(map[string]*viewedHelperDeclarations), + } + declare := func(pkg *codegen.GeneratedPackage, kind codegen.PackageNameKind, preferred string, role uint8) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(kind, preferred, codegen.UnexportedName, jsonRPCNameOrder{ + api: planned.api, + service: planned.name, + role: role, + }) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil + } + hasHTTP, hasSSE := false, false + for _, endpoint := range transport.HTTPEndpoints { + if endpoint.UsesSSE() { + hasSSE = true + } else { + hasHTTP = true + } + } + planned.clientNames.bufferPool, err = declare(client, codegen.NameVariable, "bufferPool", jsonRPCBufferPoolRole) + if err != nil { + return nil, err + } + planned.serverNames.encodeError, err = declare(server, codegen.NameFunction, "encodeJSONRPCError", jsonRPCEncodeErrorRole) + if err != nil { + return nil, err + } + if hasHTTP { + planned.serverNames.batchWriter, err = declare(server, codegen.NameType, "batchWriter", jsonRPCBatchWriterRole) + if err != nil { + return nil, err + } + } + if hasSSE { + planned.serverNames.sseStream, err = declare(server, codegen.NameType, "sseServerStream", jsonRPCSSEStreamRole) + if err != nil { + return nil, err + } + planned.serverNames.sseBuffer, err = declare(server, codegen.NameType, "sseEventBuffer", jsonRPCSSEBufferRole) + if err != nil { + return nil, err + } + } + for _, endpoint := range transport.HTTPEndpoints { + method := endpoint.MethodExpr + if _, ok := method.Result.Type.(*expr.ResultTypeExpr); !ok { + continue + } + if planned.bodyDecoder == nil { + planned.bodyDecoder = codegen.NewPreferredName( + codegen.NameFunction, + "decodeJSONRPCResult", + codegen.UnexportedName, + jsonRPCNameOrder{api: planned.api, service: planned.name, role: viewedBodyDecoderRole}, + ) + if err := client.DeclareName(planned.bodyDecoder); err != nil { + return nil, err + } + } + methodName := codegen.Goify(method.Name, true) + helpers := &viewedHelperDeclarations{ + decode: codegen.NewPreferredName( + codegen.NameFunction, + "decode"+methodName+"ViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{api: planned.api, service: planned.name, method: method.Name, role: viewedResultDecoderRole}, + ), + encode: codegen.NewPreferredName( + codegen.NameFunction, + "encode"+methodName+"ViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{api: planned.api, service: planned.name, method: method.Name, role: viewedResultEncoderRole}, + ), + streamEncode: codegen.NewPreferredName( + codegen.NameFunction, + "encode"+methodName+"Result", + codegen.UnexportedName, + jsonRPCNameOrder{api: planned.api, service: planned.name, method: method.Name, role: viewedStreamEncoderRole}, + ), + writeMetadata: codegen.NewPreferredName( + codegen.NameFunction, + "write"+methodName+"ViewedResponseMetadata", + codegen.UnexportedName, + jsonRPCNameOrder{api: planned.api, service: planned.name, method: method.Name, role: viewedMetadataWriterRole}, + ), + } + if err := client.DeclareName(helpers.decode); err != nil { + return nil, err + } + if err := server.DeclareName(helpers.encode); err != nil { + return nil, err + } + if err := server.DeclareName(helpers.streamEncode); err != nil { + return nil, err + } + if err := server.DeclareName(helpers.writeMetadata); err != nil { + return nil, err + } + planned.helpers[method.Name] = helpers + } + return planned, nil +} + +// ComparePackageName orders Go declarations by service, method, and use. +func (o jsonRPCNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(jsonRPCNameOrder) + if compared := strings.Compare(o.api, right.api); compared != 0 { + return compared + } + if compared := strings.Compare(o.service, right.service); compared != 0 { + return compared + } + if compared := strings.Compare(o.method, right.method); compared != 0 { + return compared + } + return int(o.role) - int(right.role) +} diff --git a/jsonrpc/codegen/plan_service_test.go b/jsonrpc/codegen/plan_service_test.go new file mode 100644 index 0000000000..3b400aabd5 --- /dev/null +++ b/jsonrpc/codegen/plan_service_test.go @@ -0,0 +1,90 @@ +// This file checks that plugins can read only the finalized JSON-RPC service +// data that belongs to the exact service expression used to build a plan. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +func TestPlanServiceUsesExactExpressionAfterLink(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + plans, err := NewPlans( + generation, + PlanInput{ + Root: roots[0], + Service: services[0], + HTTP: jsonPlans[0], + ApplicationHTTP: applicationPlans[0], + }, + PlanInput{ + Root: roots[1], + Service: services[1], + HTTP: jsonPlans[1], + ApplicationHTTP: applicationPlans[1], + }, + ) + require.NoError(t, err) + require.PanicsWithValue(t, "JSON-RPC files requested before Plan.Link", func() { + plans[0].Service(roots[0].API.JSONRPC.Services[0]) + }) + + require.NoError(t, generation.Freeze()) + for _, servicePlan := range services { + require.NoError(t, servicePlan.Link()) + } + for _, httpPlan := range jsonPlans { + require.NoError(t, httpPlan.Link()) + } + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + + data, ok := plans[0].Service(roots[0].API.JSONRPC.Services[0]) + require.True(t, ok) + require.Equal(t, "First", data.Service.Name) + require.NotEmpty(t, data.ClientStructDeclaration.Name()) + require.NotEmpty(t, data.ServerStructDeclaration.Name()) + + foreign := expr.RunDSL(t, jsonRPCPlanningRootDSL("First", "/first")) + data, ok = plans[0].Service(foreign.API.JSONRPC.Services[0]) + require.False(t, ok) + require.Empty(t, data) +} + +// TestPlanServiceReturnsDetachedSnapshot verifies that changing nested values +// returned to one plugin cannot change a later read or the files already +// prepared for generation. +func TestPlanServiceReturnsDetachedSnapshot(t *testing.T) { + _, plan := linkedJSONRPCPlan(t, viewedJSONRPCPlanDSL) + service := plan.root.API.JSONRPC.Services[0] + before := renderJSONRPCFiles(t, plan.ClientFiles()) + + first, ok := plan.Service(service) + require.True(t, ok) + require.NotEmpty(t, first.Endpoints) + require.NotNil(t, first.Endpoints[0].Result) + first.Endpoints[0].Result.Ref = "changed.Result" + + second, ok := plan.Service(service) + require.True(t, ok) + require.NotEqual(t, "changed.Result", second.Endpoints[0].Result.Ref) + require.Equal(t, before, renderJSONRPCFiles(t, plan.ClientFiles())) +} + +// renderJSONRPCFiles writes file sections without changing the plan. +func renderJSONRPCFiles(t *testing.T, files []*codegen.File) string { + t.Helper() + var source strings.Builder + for _, file := range files { + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + } + return source.String() +} diff --git a/jsonrpc/codegen/plan_test.go b/jsonrpc/codegen/plan_test.go new file mode 100644 index 0000000000..709948ac38 --- /dev/null +++ b/jsonrpc/codegen/plan_test.go @@ -0,0 +1,692 @@ +// This file verifies standalone JSON-RPC planning includes the HTTP codecs and +// helpers that JSON-RPC rendering reuses. +package codegen + +import ( + "path" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +func TestPlanIncludesSharedHTTPImportAliases(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("UUID", func() { + dsl.Method("Read", func() { + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() + + clientOutput := "generated.local/gen/jsonrpc/uuid/client" + require.Equal(t, "uuid2", services.ServiceImport(clientOutput, "UUID").Name) +} + +// TestPlanRetainsServicePackageImport verifies JSON-RPC output packages use +// the service package selected before later expression changes. The retained +// preferred name must still resolve around the client's sync import. +func TestPlanRetainsServicePackageImport(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Sync", func() { + dsl.Method("Read", func() { + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + + transport := root.API.JSONRPC.Services[0] + transport.ServiceExpr.Name = "Changed" + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + client := generation.Package("generated.local/gen/jsonrpc/sync/client") + require.Equal(t, "sync2", client.ImportName("generated.local/gen/sync")) +} + +// TestNewExamplePlanRejectsAnotherServicePlan checks that server names and +// URLs cannot come from a different design with the same authored names. +func TestNewExamplePlanRejectsAnotherServicePlan(t *testing.T) { + _, transport := linkedJSONRPCPlan(t, viewedJSONRPCPlanDSL) + otherRoot := expr.RunDSL(t, viewedJSONRPCPlanDSL) + otherGeneration, err := codegen.NewGeneration("other.local/gen", []eval.Root{otherRoot}) + require.NoError(t, err) + otherService, err := service.NewPlan(otherRoot, otherGeneration, expr.NewExampleGenerator(otherRoot.API.RandomizerFactory)) + require.NoError(t, err) + examples, err := example.NewPlan(otherGeneration, otherService) + require.NoError(t, err) + + _, err = NewExamplePlan(transport, examples) + require.EqualError(t, err, "JSON-RPC examples require server data created from the same service design") +} + +// TestPlanReservesGeneratedJSONRPCPackages verifies that the JSON-RPC client, +// server, and CLI imports are frozen by their complete generated paths. +func TestPlanReservesGeneratedJSONRPCPackages(t *testing.T) { + root := expr.RunDSL(t, func() { + for _, name := range []string{"Foo", "Fooc", "Foojssvr"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { dsl.JSONRPC(func() {}) }) + }) + } + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() + + cliOutput := path.Join( + "generated.local/gen/jsonrpc/cli", + codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true)), + ) + client := services.PackageImport(cliOutput, "generated.local/gen/jsonrpc/foo/client") + serverOutput := path.Join("generated.local", "cmd", codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true))) + server := services.PackageImport(serverOutput, "generated.local/gen/jsonrpc/foo/server") + require.Equal(t, "fooc", client.Name) + require.Equal(t, "foojssvr", server.Name) +} + +// TestNewPlansRequiresEveryJSONRPCRoot verifies that planning cannot reserve +// names from only part of a generation. The caller must supply each root that +// declares a JSON-RPC service exactly once. +func TestNewPlansRequiresEveryJSONRPCRoot(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + + _, err := NewPlans(generation, PlanInput{ + Root: roots[0], + Service: services[0], + HTTP: jsonPlans[0], + ApplicationHTTP: applicationPlans[0], + }) + require.EqualError(t, err, "JSON-RPC planning requires all 2 JSON-RPC roots, got 1") + assertViewedHelperNameAvailable(t, generation) +} + +// TestNewPlansRejectsDuplicateRoot verifies that two inputs cannot plan the +// same JSON-RPC root. The rejected call must not consume a helper name that a +// later generator can use. +func TestNewPlansRejectsDuplicateRoot(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + input := PlanInput{ + Root: roots[0], + Service: services[0], + HTTP: jsonPlans[0], + ApplicationHTTP: applicationPlans[0], + } + + _, err := NewPlans(generation, input, input) + require.EqualError(t, err, "JSON-RPC root is planned more than once: First") + assertViewedHelperNameAvailable(t, generation) +} + +// TestNewPlansRejectsRootWithoutJSONRPC verifies that inputs contain only +// roots that declare JSON-RPC services. Ordinary HTTP roots are planned by the +// HTTP generator and must not influence JSON-RPC names. +func TestNewPlansRejectsRootWithoutJSONRPC(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + + _, err := NewPlans(generation, + PlanInput{Root: roots[0], Service: services[0], HTTP: jsonPlans[0], ApplicationHTTP: applicationPlans[0]}, + PlanInput{Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + PlanInput{Root: roots[2], Service: services[2], HTTP: jsonPlans[0], ApplicationHTTP: applicationPlans[2]}, + ) + require.EqualError(t, err, "root does not declare JSON-RPC services") + assertViewedHelperNameAvailable(t, generation) +} + +// TestNewPlansRejectsMismatchedInputPlans verifies that every service and HTTP +// plan belongs to the root in the same input. Validation finishes before any +// JSON-RPC helper name is submitted. +func TestNewPlansRejectsMismatchedInputPlans(t *testing.T) { + tests := []struct { + name string + change func([]*expr.RootExpr, []*service.Plan, []*httpcodegen.Plan, []*httpcodegen.Plan) []PlanInput + error string + }{ + { + name: "service", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "JSON-RPC service plan does not belong to root First", + }, + { + name: "JSON-RPC HTTP", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[0], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[0]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "JSON-RPC HTTP plan does not belong to root First and its service plan", + }, + { + name: "ordinary HTTP plan in JSON-RPC role", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[0], HTTP: applicationPlans[0], ApplicationHTTP: applicationPlans[0]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "JSON-RPC HTTP plan does not belong to root First and its service plan", + }, + { + name: "application HTTP", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[0], HTTP: jsonPlans[0], ApplicationHTTP: applicationPlans[1]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "application HTTP plan does not belong to root First and its service plan", + }, + { + name: "JSON-RPC plan in application HTTP role", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[0], HTTP: jsonPlans[0], ApplicationHTTP: jsonPlans[0]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "application HTTP plan does not belong to root First and its service plan", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + _, err := NewPlans(generation, test.change(roots, services, jsonPlans, applicationPlans)...) + require.EqualError(t, err, test.error) + assertViewedHelperNameAvailable(t, generation) + }) + } +} + +// TestPlanEmitsViewedEncoderForJSONRPCMethodOnHTTPService verifies that a +// service with ordinary HTTP and JSON-RPC methods writes the viewed-result +// encoder called by its generated JSON-RPC server. +func TestPlanEmitsViewedEncoderForJSONRPCMethodOnHTTPService(t *testing.T) { + root := expr.RunDSL(t, viewedJSONRPCWithHTTPServiceDSL) + plan := CreateJSONRPCPlan(root) + require.Len(t, plan.services, 1) + require.Len(t, plan.services[0].endpoints, 1) + require.NotNil(t, plan.services[0].endpoints[0].viewed) + helper := plan.services[0].helpers["JSONRPC"].encode.Name() + + var source strings.Builder + for _, file := range plan.ServerFiles() { + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + } + require.Contains(t, source.String(), "func "+helper+"(") +} + +// TestPlanRequiresLinkBeforeRender verifies callers cannot read files before +// Link finishes or ask Link to build the same files twice. +func TestPlanRequiresLinkBeforeRender(t *testing.T) { + root := expr.RunDSL(t, viewedJSONRPCPlanDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.PanicsWithValue(t, "JSON-RPC files requested before Plan.Link", func() { + plans[0].ServerFiles() + }) + + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, plans[0].Link()) + require.EqualError(t, plans[0].Link(), "JSON-RPC plan is already linked") +} + +// TestPlanBuildsCombinedExampleWithoutChangingHTTP verifies Link creates a new +// runnable server with ordinary HTTP and JSON-RPC services and leaves the HTTP +// plan's file unchanged. +func TestPlanBuildsCombinedExampleWithoutChangingHTTP(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("mixed", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { dsl.GET("/read") }) + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + applicationPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{ + Root: root, + Service: servicePlan, + HTTP: httpPlans[0], + ApplicationHTTP: applicationPlans[0], + }) + require.NoError(t, err) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, applicationPlans[0].Link()) + require.NoError(t, httpPlans[0].Link()) + + httpExamples, err := httpcodegen.NewExamplePlan(applicationPlans[0], examplePlan) + require.NoError(t, err) + httpFile := httpExamples.ServerFiles()[0] + httpImports := append([]*codegen.ImportSpec(nil), httpFile.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)...) + require.NoError(t, plans[0].Link()) + + require.Equal(t, httpImports, httpFile.SectionTemplates[0].Data.(map[string]any)["Imports"]) + for _, section := range httpFile.SectionTemplates { + switch section.Name { + case "server-http-start", "server-http-init", "server-http-end": + require.Empty(t, section.Data.(map[string]any)["JSONRPCServices"]) + } + } + examples, err := NewExamplePlan(plans[0], examplePlan) + require.NoError(t, err) + combined := examples.ServerFiles()[0] + require.NotSame(t, httpFile, combined) + for _, section := range combined.SectionTemplates { + switch section.Name { + case "server-http-start", "server-http-init", "server-http-end": + require.Len(t, section.Data.(map[string]any)["JSONRPCServices"], 1) + } + } +} + +// TestPlanUsesHTTPViewedRepresentationBranches verifies each method uses the +// body types and constructors that the HTTP plan prepared for its result views. +func TestPlanUsesHTTPViewedRepresentationBranches(t *testing.T) { + shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCPlanDSL) + require.Len(t, plan.services, 1) + + endpoints := make(map[string]*endpointPlan) + for _, endpoint := range plan.services[0].endpoints { + endpoints[endpoint.Method.Name] = endpoint + } + variable := endpoints["fetch"].viewed + require.True(t, variable.variable) + httpViewed, ok := shared.ViewedResult("retained", "fetch") + require.True(t, ok) + require.Len(t, variable.branches, len(httpViewed.Representations)) + for index, branch := range variable.branches { + require.Equal(t, httpViewed.Representations[index].View, branch.view) + } + for _, branch := range variable.branches { + require.NotNil(t, branch.serverBody) + require.NotNil(t, branch.clientBody) + require.NotEmpty(t, branch.resultInit.Name) + } + + fixed := endpoints["fixed"].viewed + require.False(t, fixed.variable) + require.Equal(t, "detailed", fixed.fixedView) + require.Len(t, fixed.branches, 1) + require.Equal(t, "detailed", fixed.branches[0].view) +} + +// TestPlanUsesEveryViewForMappedResultField verifies that a JSON-RPC response +// mapped to one result field still carries and checks every view that the +// service method may return. Each branch uses the mapped field's JSON body and +// result constructor supplied by the HTTP plan. +func TestPlanUsesEveryViewForMappedResultField(t *testing.T) { + shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCMappedFieldPlanDSL) + httpViewed, ok := shared.ViewedResult("mapped", "fetch") + require.True(t, ok) + representations := httpViewed.Representations + require.Len(t, representations, 2) + require.Len(t, plan.services, 1) + require.Len(t, plan.services[0].endpoints, 1) + viewed := plan.services[0].endpoints[0].viewed + require.True(t, viewed.variable) + require.Empty(t, viewed.fixedView) + require.Len(t, viewed.branches, 2) + for index, name := range []string{"summary", "default"} { + require.Equal(t, name, viewed.branches[index].view) + require.Equal(t, representations[index].ServerBody, viewed.branches[index].serverBody) + require.Equal(t, representations[index].ClientBody, viewed.branches[index].clientBody) + require.Equal(t, representations[index].ResultInit, viewed.branches[index].resultInit) + } + require.Equal(t, viewed.branches[0].serverBody, viewed.branches[1].serverBody) + require.Equal(t, viewed.branches[0].clientBody, viewed.branches[1].clientBody) + require.Equal(t, viewed.branches[0].resultInit, viewed.branches[1].resultInit) +} + +// TestPlanTreatsSoleResultViewAsFixed verifies that a result type with one view +// produces a response body without a per-response view field. The service plan +// supplies that one view name, and JSON-RPC copies it without deriving a value +// from the first response branch. +func TestPlanTreatsSoleResultViewAsFixed(t *testing.T) { + shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCSoleViewPlanDSL) + httpViewed, ok := shared.ViewedResult("sole", "fetch") + require.True(t, ok) + representations := httpViewed.Representations + require.Len(t, representations, 1) + require.Len(t, plan.services, 1) + require.Len(t, plan.services[0].endpoints, 1) + viewed := plan.services[0].endpoints[0].viewed + require.False(t, viewed.variable) + require.Equal(t, "default", viewed.fixedView) + require.Len(t, viewed.branches, 1) + require.Equal(t, "default", viewed.branches[0].view) +} + +// TestPlanUsesAssignedViewedHelperNames verifies that result-view helpers use +// the unique names assigned when two method spellings produce the same Go name +// or another generated function already uses the requested name. +func TestPlanUsesAssignedViewedHelperNames(t *testing.T) { + root := expr.RunDSL(t, viewedJSONRPCCollisionPlanDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + client, err := generation.ClaimPackage("generated.local/gen/jsonrpc/collisions/client") + require.NoError(t, err) + server, err := generation.ClaimPackage("generated.local/gen/jsonrpc/collisions/server") + require.NoError(t, err) + require.NoError(t, client.DeclareName(codegen.NewPreferredName( + codegen.NameFunction, + "decodeFetchItemViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{role: viewedResultDecoderRole}, + ))) + require.NoError(t, client.DeclareName(codegen.NewPreferredName( + codegen.NameFunction, + "decodeJSONRPCResult", + codegen.UnexportedName, + jsonRPCNameOrder{}, + ))) + require.NoError(t, server.DeclareName(codegen.NewPreferredName( + codegen.NameFunction, + "encodeFetchItemViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{role: viewedResultEncoderRole}, + ))) + + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, plans[0].Link()) + + helpers := plans[0].services[0].helpers + require.Equal(t, "decodeJSONRPCResult2", plans[0].services[0].bodyDecoder.Name()) + require.Equal(t, "decodeFetchItemViewedResult2", helpers["fetch-item"].decode.Name()) + require.Equal(t, "encodeFetchItemViewedResult2", helpers["fetch-item"].encode.Name()) + require.NotEqual(t, helpers["fetch-item"].decode.Name(), helpers["fetch_item"].decode.Name()) + require.NotEqual(t, helpers["fetch-item"].encode.Name(), helpers["fetch_item"].encode.Name()) +} + +// linkedJSONRPCPlan evaluates one design, assigns all generated Go names, and +// links the service, HTTP, and JSON-RPC plans used by a test. +func linkedJSONRPCPlan(t *testing.T, design func()) (*httpcodegen.Plan, *Plan) { + t.Helper() + root := expr.RunDSL(t, design) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, plans[0].Link()) + return httpPlans[0], plans[0] +} + +// jsonRPCPlanningInputs builds two roots and their matching service, ordinary +// HTTP, and JSON-RPC HTTP plans. Tests change one input before calling NewPlans +// to prove the constructor rejects an incomplete or mismatched set. +func jsonRPCPlanningInputs(t *testing.T) (*codegen.Generation, []*expr.RootExpr, []*service.Plan, []*httpcodegen.Plan, []*httpcodegen.Plan) { + t.Helper() + roots := []*expr.RootExpr{ + expr.RunDSL(t, jsonRPCPlanningRootDSL("First", "/first")), + expr.RunDSL(t, jsonRPCPlanningRootDSL("Second", "/second")), + expr.RunDSL(t, ordinaryHTTPPlanningRootDSL), + } + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{roots[0], roots[1], roots[2]}) + require.NoError(t, err) + servicePlans, err := service.NewPlans(generation, + service.PlanInput{Root: roots[0], Examples: expr.NewExampleGenerator(roots[0].API.RandomizerFactory)}, + service.PlanInput{Root: roots[1], Examples: expr.NewExampleGenerator(roots[1].API.RandomizerFactory)}, + service.PlanInput{Root: roots[2], Examples: expr.NewExampleGenerator(roots[2].API.RandomizerFactory)}, + ) + require.NoError(t, err) + httpInputs := []httpcodegen.PlanInput{ + {Root: roots[0], Service: servicePlans[0]}, + {Root: roots[1], Service: servicePlans[1]}, + {Root: roots[2], Service: servicePlans[2]}, + } + applicationPlans, err := httpcodegen.NewPlans(generation, httpInputs...) + require.NoError(t, err) + jsonPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpInputs[:2]...) + require.NoError(t, err) + return generation, roots, servicePlans, jsonPlans, applicationPlans +} + +// assertViewedHelperNameAvailable submits the helper name that a rejected plan +// would have used and verifies no earlier JSON-RPC input consumed it. +func assertViewedHelperNameAvailable(t *testing.T, generation *codegen.Generation) { + t.Helper() + client, err := generation.ClaimPackage(path.Join("generated.local/gen/jsonrpc/first/client")) + require.NoError(t, err) + declaration := codegen.NewPreferredName( + codegen.NameFunction, + "decodeReadViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{service: "zzzz", method: "read", role: viewedResultDecoderRole}, + ) + require.NoError(t, client.DeclareName(declaration)) + require.NoError(t, generation.Freeze()) + require.Equal(t, "decodeReadViewedResult", declaration.Name()) +} + +// jsonRPCPlanningRootDSL defines one viewed method exposed through both +// ordinary HTTP and JSON-RPC so tests can also validate ApplicationHTTP. +func jsonRPCPlanningRootDSL(name, route string) func() { + return func() { + result := dsl.ResultType("application/vnd."+strings.ToLower(name), func() { + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("default", func() { + dsl.Attribute("id") + }) + }) + dsl.Service(name, func() { + dsl.Method("read", func() { + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET(route) + }) + dsl.JSONRPC(func() {}) + }) + }) + } +} + +// ordinaryHTTPPlanningRootDSL defines a root that must be excluded from +// JSON-RPC inputs even though it participates in the same generation. +func ordinaryHTTPPlanningRootDSL() { + dsl.Service("Ordinary", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/ordinary") + }) + }) + }) +} + +// viewedJSONRPCWithHTTPServiceDSL defines one service whose ordinary HTTP and +// JSON-RPC methods return the same one-view result type. +func viewedJSONRPCWithHTTPServiceDSL() { + result := dsl.ResultType("application/vnd.viewed-jsonrpc-http-service", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { + dsl.Attribute("value") + }) + }) + dsl.Service("ViewedHTTPJSON", func() { + dsl.Method("HTTP", func() { + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET("/http") + }) + }) + dsl.Method("JSONRPC", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) +} + +// viewedJSONRPCPlanDSL defines one variable and one fixed viewed result. +func viewedJSONRPCPlanDSL() { + result := dsl.ResultType("application/vnd.retained-view", func() { + dsl.TypeName("RetainedView") + dsl.Attribute("id", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("id", "detail") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + dsl.View("detailed", func() { + dsl.Attribute("id") + dsl.Attribute("detail") + }) + }) + dsl.Service("retained", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + dsl.Method("fixed", func() { + dsl.Result(result, func() { + dsl.View("detailed") + }) + dsl.JSONRPC(func() {}) + }) + }) +} + +// viewedJSONRPCCollisionPlanDSL defines two viewed methods whose authored +// names normalize to the same preferred Go helper spelling. +func viewedJSONRPCCollisionPlanDSL() { + result := dsl.ResultType("application/vnd.retained-collision", func() { + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + dsl.View("detailed", func() { + dsl.Attribute("id") + }) + }) + dsl.Service("collisions", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + for _, name := range []string{"fetch-item", "fetch_item"} { + dsl.Method(name, func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + } + }) +} + +// viewedJSONRPCMappedFieldPlanDSL defines a result whose JSON-RPC response body +// contains only the id field while the selected view still accompanies the +// response and must be either the generated default view or the summary view. +func viewedJSONRPCMappedFieldPlanDSL() { + result := dsl.ResultType("application/vnd.mapped-view", func() { + dsl.Attribute("id", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + }) + dsl.Service("mapped", func() { + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() { + dsl.Response(func() { + dsl.Body("id") + }) + }) + }) + }) +} + +// viewedJSONRPCSoleViewPlanDSL defines a result whose only legal view is the +// default view and does not repeat that choice on the method. +func viewedJSONRPCSoleViewPlanDSL() { + result := dsl.ResultType("application/vnd.sole-view", func() { + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("default", func() { + dsl.Attribute("id") + }) + }) + dsl.Service("sole", func() { + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) +} diff --git a/jsonrpc/codegen/server.go b/jsonrpc/codegen/server.go index ec2d94802c..db9e8057ac 100644 --- a/jsonrpc/codegen/server.go +++ b/jsonrpc/codegen/server.go @@ -1,3 +1,5 @@ +// This file writes JSON-RPC server handlers, request decoders, and response +// encoders for each service. Each file imports only the types it uses. package codegen import ( @@ -6,32 +8,43 @@ import ( "strings" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// ServerFiles returns the generated JSON-RPC server files if any. -func ServerFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File { - jsvcs := data.Root.API.JSONRPC.Services - files := make([]*codegen.File, 0, len(jsvcs)*3) - for _, svc := range jsvcs { - files = append(files, serverFile(genpkg, svc, data)) - // Generate either WebSocket or SSE file based on transport type - if hasJSONRPCSSE(svc) { - if f := sseServerFile(genpkg, svc, data); f != nil { - files = append(files, f) +type ( + // serverTemplateData stores the service values and extra Go names used to + // write one server package. + serverTemplateData struct { + httpcodegen.JSONRPCServiceSnapshot + // BatchWriter is the type that joins responses for one batch request. + BatchWriter *codegen.NameDeclaration + // EncodeError is the function that writes a JSON-RPC error response. + EncodeError *codegen.NameDeclaration + // SSEStream is the stream shared by all server-sent-event methods. + SSEStream *codegen.NameDeclaration + } +) + +// serverFiles builds server, stream, and JSON conversion files from the +// services recorded before every generated Go name was assigned. +func serverFiles(services []*servicePlan) []*codegen.File { + files := make([]*codegen.File, 0, len(services)*3) + for _, planned := range services { + renderPlan := servicePlanForOutput(planned, false) + files = append(files, addFileImports(serverFile(renderPlan), planned.data)) + if renderPlan.hasSSE { + if f := sseServerFile(renderPlan); f != nil { + files = append(files, addFileImports(f, planned.data)) } - } else if f := websocketServerFile(genpkg, svc, data); f != nil { - files = append(files, f) } } - for _, svc := range jsvcs { - f := httpcodegen.ServerEncodeDecodeFile(genpkg, svc, data) + for _, planned := range services { + f := planned.data.ServerCodecFile() if f == nil { continue } for _, s := range f.SectionTemplates { - // Add the JSON-RPC imports. + // These imports are used by the JSON-RPC error and body converters below. if s.Name == "source-header" { codegen.AddImport(s, &codegen.ImportSpec{Path: "bytes"}) codegen.AddImport(s, &codegen.ImportSpec{Path: "io"}) @@ -39,24 +52,34 @@ func ServerFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File } s.Name = "jsonrpc-" + s.Name } - files = append(files, f) + files = append(files, addFileImports(f, planned.data)) } return files } // serverFile returns the file implementing the JSON-RPC server. -func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) +func serverFile(planned *servicePlan) *codegen.File { + data := planned.data + renderData := &serverTemplateData{ + JSONRPCServiceSnapshot: data, + BatchWriter: planned.serverNames.batchWriter, + EncodeError: planned.serverNames.encodeError, + SSEStream: planned.serverNames.sseStream, + } svcName := data.Service.PathName fpath := filepath.Join(codegen.Gendir, "jsonrpc", svcName, "server", "server.go") - title := fmt.Sprintf("%s JSON-RPC server", svc.Name()) + title := fmt.Sprintf("%s JSON-RPC server", planned.name) funcs := map[string]any{ - "isWebSocketEndpoint": httpcodegen.IsWebSocketEndpoint, - "isSSEEndpoint": httpcodegen.IsSSEEndpoint, - "lowerInitial": lowerInitial, - "hasMixedTransports": func() bool { return hasMixedJSONRPCTransports(svc) }, + "isSSEEndpoint": isJSONRPCSSEEndpoint, + "lowerInitial": lowerInitial, + "encodeErrorName": planned.encodeErrorName, + "sseStreamName": planned.sseStreamName, + "hasMixedTransports": planned.hasMixedTransports, + } + for name, function := range viewedResultFuncs(planned) { + funcs[name] = function } - imports := make([]*codegen.ImportSpec, 0, 15+len(data.Service.UserTypeImports)) + imports := make([]*codegen.ImportSpec, 0, 15) imports = append(imports, &codegen.ImportSpec{Path: "bufio"}, &codegen.ImportSpec{Path: "bytes"}, @@ -64,6 +87,7 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen. &codegen.ImportSpec{Path: "errors"}, &codegen.ImportSpec{Path: "fmt"}, &codegen.ImportSpec{Path: "io"}, + &codegen.ImportSpec{Path: "mime"}, &codegen.ImportSpec{Path: "mime/multipart"}, &codegen.ImportSpec{Path: "net/http"}, &codegen.ImportSpec{Path: "path"}, @@ -71,92 +95,95 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen. codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - &codegen.ImportSpec{Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - &codegen.ImportSpec{Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, + data.ServerServiceImport(), ) - imports = append(imports, data.Service.UserTypeImports...) + if serviceNeedsMetadataStrconv(planned) || planned.hasHTTP && planned.hasSSE { + imports = append(imports, &codegen.ImportSpec{Path: "strconv"}) + } + if serviceHasViewedResult(data) { + imports = append(imports, data.ServerViewImport()) + } sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), } sections = append(sections, - &codegen.SectionTemplate{Name: "jsonrpc-server-struct", Source: jsonrpcTemplates.Read(serverStructT), FuncMap: funcs, Data: data}, - &codegen.SectionTemplate{Name: "jsonrpc-server-init", Source: jsonrpcTemplates.Read(serverInitT), Data: data, FuncMap: funcs}, - &codegen.SectionTemplate{Name: "jsonrpc-server-service", Source: httpcodegen.ReadTemplate(serverServiceT), Data: data}, - &codegen.SectionTemplate{Name: "jsonrpc-server-use", Source: jsonrpcTemplates.Read(serverUseT), Data: data}, - &codegen.SectionTemplate{Name: "jsonrpc-server-method-names", Source: httpcodegen.ReadTemplate(serverMethodNamesT), Data: data}, + &codegen.SectionTemplate{Name: "jsonrpc-server-struct", Source: jsonrpcTemplates.Read(serverStructT), FuncMap: funcs, Data: renderData}, + &codegen.SectionTemplate{Name: "jsonrpc-server-init", Source: jsonrpcTemplates.Read(serverInitT), Data: renderData, FuncMap: funcs}, + &codegen.SectionTemplate{Name: "jsonrpc-server-service", Source: jsonrpcTemplates.Read(serverServiceT), Data: renderData}, + &codegen.SectionTemplate{Name: "jsonrpc-server-use", Source: jsonrpcTemplates.Read(serverUseT), Data: renderData}, + &codegen.SectionTemplate{Name: "jsonrpc-server-method-names", Source: jsonrpcTemplates.Read(serverMethodNamesT), Data: renderData}, ) - // Use appropriate server handler based on transport + // Add the request handlers needed by this service. switch { - case hasMixedJSONRPCTransports(svc): - // For mixed transports, we need a unified handler with content negotiation - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-mixed-server-handler", Source: jsonrpcTemplates.Read(mixedServerHandlerT), FuncMap: funcs, Data: data}) - // Include the standard HTTP handlers that the mixed handler delegates to - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: data}) - // Also include SSE handler for SSE-specific logic - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: data}) - case hasJSONRPCSSE(svc): - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: data}) - case httpcodegen.HasWebSocket(data): - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-websocket-server-handler", Source: jsonrpcTemplates.Read(websocketServerHandlerT), FuncMap: funcs, Data: data}) + case planned.hasHTTP && planned.hasSSE: + // ServeHTTP chooses an ordinary JSON-RPC response or server-sent events + // from the request's Accept header. + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-mixed-server-handler", Source: jsonrpcTemplates.Read(mixedServerHandlerT), FuncMap: funcs, Data: renderData}) + // Add both handlers called by ServeHTTP. + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: renderData}) + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: renderData}) + case planned.hasSSE: + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: renderData}) default: - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: data}) + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: renderData}) } - // Add transport flags to data + // Record which request handlers this service needs. mountData := struct { - *httpcodegen.ServiceData + httpcodegen.JSONRPCServiceSnapshot HasSSE bool HasMixed bool }{ - ServiceData: data, - HasSSE: hasJSONRPCSSE(svc), - HasMixed: hasMixedJSONRPCTransports(svc), + JSONRPCServiceSnapshot: data, + HasSSE: planned.hasSSE, + HasMixed: planned.hasHTTP && planned.hasSSE, } sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-mount", Source: jsonrpcTemplates.Read(serverMountT), Data: mountData}, ) - for _, e := range data.Endpoints { + for _, e := range planned.endpoints { sections = append(sections, - &codegen.SectionTemplate{Name: "jsonrpc-server-handler-init", Source: jsonrpcTemplates.Read(serverHandlerInitT), FuncMap: funcs, Data: e}) + &codegen.SectionTemplate{Name: "jsonrpc-server-handler-init", Source: jsonrpcTemplates.Read(serverHandlerInitT), FuncMap: funcs, Data: &e.JSONRPCEndpointSnapshot}) } + sections = append(sections, serverViewedResultSections(planned)...) - if !httpcodegen.HasWebSocket(data) { - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-encode-error", Source: jsonrpcTemplates.Read(serverEncodeErrorT)}) - } + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-encode-error", Source: jsonrpcTemplates.Read(serverEncodeErrorT), Data: renderData}) return &codegen.File{Path: fpath, SectionTemplates: sections} } -// lowerInitial returns the string with the first letter in lowercase. -func lowerInitial(s string) string { - return strings.ToLower(s[:1]) + s[1:] +// encodeErrorName returns the function that writes a JSON-RPC error response. +func (s *servicePlan) encodeErrorName() string { + return s.serverNames.encodeError.Name() } -// hasJSONRPCSSE returns true if the service uses SSE for JSON-RPC streaming. -func hasJSONRPCSSE(svc *expr.HTTPServiceExpr) bool { - for _, e := range svc.HTTPEndpoints { - if e.MethodExpr.IsStreaming() && e.IsJSONRPC() && e.SSE != nil { - return true - } - } - return false +// sseStreamName returns the shared server-sent-event stream type. +func (s *servicePlan) sseStreamName() string { + return s.serverNames.sseStream.Name() } -// hasJSONRPCHTTP returns true if the service has non-streaming JSON-RPC endpoints. -func hasJSONRPCHTTP(svc *expr.HTTPServiceExpr) bool { - for _, e := range svc.HTTPEndpoints { - if e.IsJSONRPC() && !e.MethodExpr.IsStreaming() { +// hasMixedTransports reports whether the server accepts ordinary JSON-RPC +// requests and server-sent-event requests on the same HTTP path. +func (s *servicePlan) hasMixedTransports() bool { + return s.hasHTTP && s.hasSSE +} + +// serviceHasViewedResult reports whether server.go emits endpoint conversion +// code that references the service views package. +func serviceHasViewedResult(service httpcodegen.JSONRPCServiceSnapshot) bool { + for _, endpoint := range service.Endpoints { + if endpoint.Method.ViewedResult != nil { return true } } return false } -// hasMixedJSONRPCTransports returns true if the service has both HTTP and SSE JSON-RPC endpoints. -func hasMixedJSONRPCTransports(svc *expr.HTTPServiceExpr) bool { - return hasJSONRPCHTTP(svc) && hasJSONRPCSSE(svc) +// lowerInitial returns the string with the first letter in lowercase. +func lowerInitial(s string) string { + return strings.ToLower(s[:1]) + s[1:] } diff --git a/jsonrpc/codegen/server_error_contract_test.go b/jsonrpc/codegen/server_error_contract_test.go new file mode 100644 index 0000000000..4e468deec6 --- /dev/null +++ b/jsonrpc/codegen/server_error_contract_test.go @@ -0,0 +1,70 @@ +// This file verifies how generated JSON-RPC servers report request, service, +// and stream errors to callers. +package codegen + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/jsonrpc/codegen/testdata" +) + +// TestServerErrorResponses verifies the transport writes request and service +// failures without exposing JSON-RPC error methods through the service stream. +func TestServerErrorResponses(t *testing.T) { + root := expr.RunDSL(t, testdata.JSONRPCKitchenSinkDSL) + plan := CreateJSONRPCPlan(root) + + feedServer := renderPlannedFile(t, plan.ServerFiles(), "feed", "server.go") + require.Contains(t, feedServer, "return strm.sendError(ctx, req.ID, jsonrpc.InvalidParams, err.Error(), nil)") + require.Contains(t, feedServer, `"result": nil`) + require.NotContains(t, feedServer, "jsonrpc.MakeSuccessResponse(req.ID, nil)") + require.Contains(t, feedServer, "return strm.sendSSEEvent(ctx, \"response\", response)") + require.Contains(t, feedServer, "jsonrpc.MakeSuccessResponse(id, res)") + require.Equal(t, 1, strings.Count(feedServer, `mux.Handle("POST", "/feed", h.ServeHTTP)`)) + require.NotContains(t, feedServer, "SendError") + + feedStream := renderPlannedFile(t, plan.ServerFiles(), "feed", "sse.go") + require.Contains(t, feedStream, "func (s *WatchServerStream) Send(event *feed.WatchResult) error") + require.Contains(t, feedStream, "func (s *WatchServerStream) SendWithContext(ctx context.Context, event *feed.WatchResult) error") + require.Contains(t, feedStream, "func (s *WatchServerStream) Close() error") + require.NotContains(t, feedStream, "SendAndClose") + require.NotContains(t, feedStream, "SendError") + + calcServer := renderPlannedFile(t, plan.ServerFiles(), "calc", "server.go") + require.Contains(t, calcServer, "if err != nil {") + require.Contains(t, calcServer, "encodeJSONRPCError(ctx, w, req,") +} + +// TestNamedSSEPayloadReceivesLastEventID verifies a named payload receives the +// event ID in its designed pointer field before the endpoint runs. +func TestNamedSSEPayloadReceivesLastEventID(t *testing.T) { + root := expr.RunDSL(t, testdata.JSONRPCKitchenSinkDSL) + plan := CreateJSONRPCPlan(root) + feedServer := renderPlannedFile(t, plan.ServerFiles(), "feed", "server.go") + + require.Contains(t, feedServer, "params.LastEventID = &lastEventID") +} + +// renderPlannedFile renders one file stored by the plan into memory without +// writing generated output to the repository. +func renderPlannedFile(t *testing.T, files []*codegen.File, service, name string) string { + t.Helper() + for _, file := range files { + if filepath.Base(file.Path) != name || filepath.Base(filepath.Dir(filepath.Dir(file.Path))) != service { + continue + } + var source strings.Builder + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + return source.String() + } + t.Errorf("generated %s/%s file not found", service, name) + return "" +} diff --git a/jsonrpc/codegen/server_protocol_runtime_test.go b/jsonrpc/codegen/server_protocol_runtime_test.go new file mode 100644 index 0000000000..909f2e80bc --- /dev/null +++ b/jsonrpc/codegen/server_protocol_runtime_test.go @@ -0,0 +1,431 @@ +// This file renders a JSON-RPC server and runs requests whose wire form decides +// whether the server returns one response, a batch, no body, or an event stream. +package codegen_test + +import "testing" + +// TestGeneratedServerFollowsJSONRPCRequestRules checks the request forms that +// determine whether the server sends one response, a batch, or an event stream. +func TestGeneratedServerFollowsJSONRPCRequestRules(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "protocol", protocolRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/protocol/server") +} + +// TestGeneratedPureSSEServerClosesRequestBodies checks that an event-only +// server closes the body supplied by the HTTP server after the stream ends. +func TestGeneratedPureSSEServerClosesRequestBodies(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "sse_decode", pureSSEBodyRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_decode/server") +} + +const protocolRuntimeTest = `package server + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func TestRequestIDPresenceControlsResponses(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {name: "missing ID", body: ` + "`" + `{"jsonrpc":"2.0","method":"ping"}` + "`" + `}, + {name: "empty string ID", body: ` + "`" + `{"jsonrpc":"2.0","id":"","method":"ping"}` + "`" + `, want: ` + "`" + `{"jsonrpc":"2.0","id":"","result":null}` + "`" + `}, + {name: "null ID", body: ` + "`" + `{"jsonrpc":"2.0","id":null,"method":"ping"}` + "`" + `, want: ` + "`" + `{"jsonrpc":"2.0","id":null,"result":null}` + "`" + `}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := serveProtocol(test.body, "") + if test.want == "" { + require.Empty(t, response.Body.String()) + return + } + require.JSONEq(t, test.want, response.Body.String()) + }) + } +} + +func TestRequestIDPresenceControlsErrors(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {name: "missing ID", body: ` + "`" + `{"jsonrpc":"2.0","method":"missing"}` + "`" + `}, + {name: "empty string ID", body: ` + "`" + `{"jsonrpc":"2.0","id":"","method":"missing"}` + "`" + `, want: ` + "`" + `{"jsonrpc":"2.0","id":"","error":{"code":-32601,"message":"Method not found"}}` + "`" + `}, + {name: "null ID", body: ` + "`" + `{"jsonrpc":"2.0","id":null,"method":"missing"}` + "`" + `, want: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32601,"message":"Method not found"}}` + "`" + `}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := serveProtocol(test.body, "") + if test.want == "" { + require.Empty(t, response.Body.String()) + return + } + require.JSONEq(t, test.want, response.Body.String()) + }) + } +} + +func TestBatchFormFollowsJSONWhitespaceAndEmptyArrayRules(t *testing.T) { + response := serveProtocol(" \n\t["+` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `+"]", "") + require.JSONEq(t, ` + "`" + `[{"jsonrpc":"2.0","id":"one","result":null}]` + "`" + `, response.Body.String()) + + response = serveProtocol("[]", "") + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}}` + "`" + `, response.Body.String()) + + response = serveProtocol(` + "`" + `[{"jsonrpc":"2.0","method":"ping"}]` + "`" + `, "") + require.Empty(t, response.Body.String()) +} + +func TestInvalidRequestsReturnErrors(t *testing.T) { + for _, body := range []string{ + ` + "`" + `{}` + "`" + `, + ` + "`" + `{"jsonrpc":"1.0","method":"ping"}` + "`" + `, + } { + response := serveProtocol(body, "") + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}}` + "`" + `, response.Body.String()) + } +} + +func TestBatchProcessesInvalidMembersIndependently(t *testing.T) { + response := serveProtocol("[1]", "") + require.JSONEq(t, ` + "`" + `[{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}}]` + "`" + `, response.Body.String()) + + response = serveProtocol(` + "`" + `[{"jsonrpc":"2.0","id":"one","method":"ping"},1,{"jsonrpc":"2.0","method":"ping"}]` + "`" + `, "") + require.JSONEq(t, ` + "`" + `[ + {"jsonrpc":"2.0","id":"one","result":null}, + {"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}} + ]` + "`" + `, response.Body.String()) +} + +func TestAcceptQualityControlsEventStreamSelection(t *testing.T) { + response := serveProtocol(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `, "text/event-stream;q=0, application/json") + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":"one","result":null}` + "`" + `, response.Body.String()) + + response = serveProtocol(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `, "Text/Event-Stream;Q=0.5") + require.Equal(t, http.StatusNotAcceptable, response.Code) + require.Empty(t, response.Body.String()) + + response = serveProtocol(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `, "application/json, text/event-stream;q=0.5") + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":"one","result":null}` + "`" + `, response.Body.String()) +} + +func TestMixedServerSelectsTheRequestedMethodsResponseType(t *testing.T) { + tests := []struct { + name string + method string + accept string + wantCode int + wantEvent bool + wantPing int + wantWatch int + }{ + {name: "unary with both", method: "ping", accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantPing: 1}, + {name: "stream with both", method: "watch", accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantEvent: true, wantWatch: 1}, + {name: "unary with events only", method: "ping", accept: "text/event-stream", wantCode: http.StatusNotAcceptable}, + {name: "stream with JSON only", method: "watch", accept: "application/json", wantCode: http.StatusNotAcceptable}, + {name: "unary without accept", method: "ping", wantCode: http.StatusOK, wantPing: 1}, + {name: "stream without accept", method: "watch", wantCode: http.StatusOK, wantEvent: true, wantWatch: 1}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"` + "`" + ` + test.method + ` + "`" + `"}` + "`" + ` + response, calls := serveProtocolWithCalls(body, test.accept) + require.Equal(t, test.wantCode, response.Code) + require.Equal(t, test.wantPing, calls.ping) + require.Equal(t, test.wantWatch, calls.watch) + if test.wantEvent { + require.Contains(t, response.Body.String(), "event: response") + } + if test.wantCode == http.StatusNotAcceptable { + require.Empty(t, response.Body.String()) + } + }) + } +} + +func TestMixedServerChoosesAFormatForRequestErrors(t *testing.T) { + tests := []struct { + name string + body string + accept string + wantCode int + wantEvent bool + wantBody bool + }{ + {name: "malformed prefers JSON", body: "{", accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantBody: true}, + {name: "malformed uses events", body: "{", accept: "text/event-stream", wantCode: http.StatusOK, wantEvent: true, wantBody: true}, + {name: "malformed unsupported", body: "{", accept: "application/xml", wantCode: http.StatusNotAcceptable}, + {name: "invalid prefers JSON", body: ` + "`" + `{}` + "`" + `, accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantBody: true}, + {name: "unknown prefers JSON", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"missing"}` + "`" + `, accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantBody: true}, + {name: "unknown uses events", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"missing"}` + "`" + `, accept: "text/event-stream", wantCode: http.StatusOK, wantEvent: true, wantBody: true}, + {name: "unknown notification has no response", body: ` + "`" + `{"jsonrpc":"2.0","method":"missing"}` + "`" + `, accept: "text/event-stream", wantCode: http.StatusOK}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response, calls := serveProtocolWithCalls(test.body, test.accept) + require.Equal(t, test.wantCode, response.Code) + require.Zero(t, calls.ping) + require.Zero(t, calls.watch) + require.Equal(t, test.wantBody, response.Body.Len() > 0) + if test.wantEvent { + require.Contains(t, response.Body.String(), "event: error") + } + }) + } +} + +func TestMixedServerKeepsBatchesOnJSON(t *testing.T) { + body := ` + "`" + `[ + {"jsonrpc":"2.0","id":"one","method":"ping"}, + {"jsonrpc":"2.0","id":"two","method":"watch"}, + {"jsonrpc":"2.0","method":"watch"}, + {"jsonrpc":"2.0","id":"three","method":"ping"} + ]` + "`" + ` + response, calls := serveProtocolWithCalls(body, "application/json, text/event-stream") + require.Equal(t, http.StatusOK, response.Code) + require.Equal(t, 2, calls.ping) + require.Zero(t, calls.watch) + require.JSONEq(t, ` + "`" + `[ + {"jsonrpc":"2.0","id":"one","result":null}, + {"jsonrpc":"2.0","id":"two","error":{"code":-32601,"message":"Method is not available in a batch request"}}, + {"jsonrpc":"2.0","id":"three","result":null} + ]` + "`" + `, response.Body.String()) + + response, calls = serveProtocolWithCalls(body, "text/event-stream") + require.Equal(t, http.StatusNotAcceptable, response.Code) + require.Zero(t, calls.ping) + require.Zero(t, calls.watch) + require.Empty(t, response.Body.String()) +} + +func TestMixedServerClosesTheOriginalBodyOnce(t *testing.T) { + tests := []struct { + name string + body string + accept string + }{ + {name: "single", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `}, + {name: "batch", body: ` + "`" + `[{"jsonrpc":"2.0","id":"one","method":"ping"}]` + "`" + `}, + {name: "parse error", body: "{"}, + {name: "not acceptable", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `, accept: "text/event-stream"}, + {name: "stream completion", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"watch"}` + "`" + `, accept: "text/event-stream"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := &trackedRequestBody{Reader: strings.NewReader(test.body)} + serveProtocolBody(body, test.accept) + require.Equal(t, 1, body.closes) + }) + } +} + +func TestMixedServerReportsReadAndCloseErrors(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + body := &trackedRequestBody{Reader: errorReader{err: readErr}, closeErr: closeErr} + _, _, reported := serveProtocolBody(body, "application/json") + require.ErrorIs(t, errors.Join(reported...), readErr) + require.ErrorIs(t, errors.Join(reported...), closeErr) + require.Equal(t, 1, body.closes) +} + +func TestUndeclaredServiceErrorIsInternal(t *testing.T) { + err := goa.NewServiceError(errors.New("failed"), "invalid_params", false, false, false) + response, _, reported := serveProtocolBodyWithDecoder( + io.NopCloser(strings.NewReader(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `)), + "application/json, text/event-stream", + goahttp.RequestDecoder, + err, + ) + require.Empty(t, reported) + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":"one","error":{"code":-32603,"message":"failed"}}` + "`" + `, response.Body.String()) +} + +func TestMixedServerClosesTheBodySuppliedByTheHTTPServer(t *testing.T) { + original := &trackedRequestBody{Reader: strings.NewReader(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `)} + replacement := &trackedRequestBody{Reader: strings.NewReader("")} + decoder := func(r *http.Request) goahttp.Decoder { + result := goahttp.RequestDecoder(r) + r.Body = replacement + return result + } + + serveProtocolBodyWithDecoder(original, "application/json", decoder, nil) + + require.Equal(t, 1, original.closes) + require.Zero(t, replacement.closes) +} + +func serveProtocol(body, accept string) *httptest.ResponseRecorder { + response, _ := serveProtocolWithCalls(body, accept) + return response +} + +type protocolCalls struct { + ping int + watch int +} + +type trackedRequestBody struct { + io.Reader + closeErr error + closes int +} + +type errorReader struct { + err error +} + +func (reader errorReader) Read([]byte) (int, error) { + return 0, reader.err +} + +func (body *trackedRequestBody) Close() error { + body.closes++ + return body.closeErr +} + +func serveProtocolWithCalls(body, accept string) (*httptest.ResponseRecorder, *protocolCalls) { + response, calls, _ := serveProtocolBody(io.NopCloser(strings.NewReader(body)), accept) + return response, calls +} + +func serveProtocolBody(body io.ReadCloser, accept string) (*httptest.ResponseRecorder, *protocolCalls, []error) { + return serveProtocolBodyWithDecoder(body, accept, goahttp.RequestDecoder, nil) +} + +func serveProtocolBodyWithDecoder(body io.ReadCloser, accept string, decoder func(*http.Request) goahttp.Decoder, pingError error) (*httptest.ResponseRecorder, *protocolCalls, []error) { + encoder := goahttp.ResponseEncoder + var reported []error + errhandler := func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + } + calls := &protocolCalls{} + server := &Server{ + Ping: NewPingHandler( + goa.Endpoint(func(context.Context, any) (any, error) { + calls.ping++ + return nil, pingError + }), + goahttp.NewMuxer(), + decoder, + encoder, + errhandler, + ), + Watch: NewWatchHandler( + goa.Endpoint(func(context.Context, any) (any, error) { + calls.watch++ + return nil, nil + }), + goahttp.NewMuxer(), + decoder, + encoder, + errhandler, + ), + decoder: decoder, + encoder: encoder, + errhandler: errhandler, + } + request := httptest.NewRequest(http.MethodPost, "/protocol", nil) + request.Body = body + request.Header.Set("Accept", accept) + response := httptest.NewRecorder() + server.ServeHTTP(response, request) + return response, calls, reported +} +` + +const pureSSEBodyRuntimeTest = `package server + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type trackedRequestBody struct { + io.Reader + closeErr error + closes int +} + +type errorReader struct { + err error +} + +func (reader errorReader) Read([]byte) (int, error) { + return 0, reader.err +} + +func (body *trackedRequestBody) Close() error { + body.closes++ + return body.closeErr +} + +func TestPureSSEServerClosesTheOriginalBodyOnce(t *testing.T) { + body := &trackedRequestBody{Reader: strings.NewReader(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"watch","params":{"topic":"alerts"}}` + "`" + `)} + _, reported := servePureSSE(body) + require.Empty(t, reported) + require.Equal(t, 1, body.closes) +} + +func TestPureSSEServerReportsReadAndCloseErrors(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + body := &trackedRequestBody{Reader: errorReader{err: readErr}, closeErr: closeErr} + _, reported := servePureSSE(body) + require.ErrorIs(t, errors.Join(reported...), readErr) + require.ErrorIs(t, errors.Join(reported...), closeErr) + require.Equal(t, 1, body.closes) +} + +func servePureSSE(body io.ReadCloser) (*httptest.ResponseRecorder, []error) { + encoder := goahttp.ResponseEncoder + var reported []error + errhandler := func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + } + server := &Server{ + Watch: NewWatchHandler( + goa.Endpoint(func(context.Context, any) (any, error) { return nil, nil }), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + encoder, + errhandler, + ), + decoder: goahttp.RequestDecoder, + encoder: encoder, + errhandler: errhandler, + } + request := httptest.NewRequest(http.MethodPost, "/decode", nil) + request.Body = body + response := httptest.NewRecorder() + server.handleSSE(response, request) + return response, reported +} +` diff --git a/jsonrpc/codegen/service_imports.go b/jsonrpc/codegen/service_imports.go new file mode 100644 index 0000000000..9cbca42c86 --- /dev/null +++ b/jsonrpc/codegen/service_imports.go @@ -0,0 +1,15 @@ +// This file adds the imports that the HTTP plan prepared for each JSON-RPC +// output file. +package codegen + +import ( + "goa.design/goa/v3/codegen" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +// addFileImports adds the service-type imports prepared for file's path and +// returns file so callers can append it directly to their result. +func addFileImports(file *codegen.File, service httpcodegen.JSONRPCServiceSnapshot) *codegen.File { + codegen.AddImport(file.SectionTemplates[0], service.FileImports(file.Path)...) + return file +} diff --git a/jsonrpc/codegen/single_endpoint_test.go b/jsonrpc/codegen/single_endpoint_test.go index 497253ba0c..3154c1e8c4 100644 --- a/jsonrpc/codegen/single_endpoint_test.go +++ b/jsonrpc/codegen/single_endpoint_test.go @@ -110,43 +110,4 @@ func TestJSONRPCSingleEndpoint(t *testing.T) { require.NotNil(t, svc.Meta) assert.NotNil(t, svc.Meta["jsonrpc:service"], "service should be auto-marked as JSON-RPC") }) - - t.Run("WebSocket forces GET", func(t *testing.T) { - root := expr.RunDSL(t, func() { - dsl.Service("stream", func() { - dsl.JSONRPC(func() {}) - - dsl.Method("echo", func() { - dsl.StreamingPayload(func() { - dsl.ID("id") - dsl.Attribute("msg", dsl.String) - }) - dsl.StreamingResult(func() { - dsl.ID("id") - dsl.Attribute("echo", dsl.String) - }) - dsl.JSONRPC(func() {}) - }) - }) - }) - - // Check route method - httpSvc := root.API.JSONRPC.Service("stream") - require.NotNil(t, httpSvc) - - // Prepare the service to create routes - httpSvc.Prepare() - - // Find first endpoint with route - var route *expr.RouteExpr - for _, e := range httpSvc.HTTPEndpoints { - if e.IsJSONRPC() && len(e.Routes) > 0 { - route = e.Routes[0] - break - } - } - - require.NotNil(t, route) - assert.Equal(t, "GET", route.Method, "WebSocket should force GET method") - }) } diff --git a/jsonrpc/codegen/sse.go b/jsonrpc/codegen/sse.go index 08c64733df..cf5868b526 100644 --- a/jsonrpc/codegen/sse.go +++ b/jsonrpc/codegen/sse.go @@ -1,3 +1,5 @@ +// This file renders JSON-RPC server-sent-event clients and servers. Each file +// imports only the generated service types used by its stream methods. package codegen import ( @@ -5,69 +7,84 @@ import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" - httpcodegen "goa.design/goa/v3/http/codegen" ) -// sseServerFile returns the file implementing the JSON-RPC SSE server -// streams if any. The file contains the shared SSE stream machinery followed -// by one stream implementation per SSE endpoint. -func sseServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) - if data == nil { - return nil +type ( + // sseServerTemplateData stores the two Go names shared by every server stream + // in one service. + sseServerTemplateData struct { + // Stream stores the response writer and encoder. + Stream *codegen.NameDeclaration + // Buffer stores an encoded event before the response starts. + Buffer *codegen.NameDeclaration } - if !hasSSEEndpoint(data) { +) + +// sseServerFile returns the JSON-RPC server-sent-event file when the service +// has a method that sends events. The file writes the shared event sender once, +// followed by one stream type for each method. +func sseServerFile(planned *servicePlan) *codegen.File { + data := planned.data + if !planned.hasSSE { return nil } path := filepath.Join(codegen.Gendir, "jsonrpc", data.Service.PathName, "server", "sse.go") - title := fmt.Sprintf("%s SSE server streaming", svc.Name()) - imports := make([]*codegen.ImportSpec, 0, 9+len(data.Service.UserTypeImports)) + title := fmt.Sprintf("%s SSE server streaming", planned.name) + imports := make([]*codegen.ImportSpec, 0, 9) imports = append(imports, + &codegen.ImportSpec{Path: "bytes"}, &codegen.ImportSpec{Path: "context"}, - &codegen.ImportSpec{Path: "errors"}, &codegen.ImportSpec{Path: "fmt"}, &codegen.ImportSpec{Path: "net/http"}, &codegen.ImportSpec{Path: "sync"}, - codegen.GoaImport(""), codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - &codegen.ImportSpec{Path: genpkg + "/" + data.Service.PathName, Name: data.Service.PkgName}, + data.ServerServiceImport(), ) - imports = append(imports, data.Service.UserTypeImports...) + for _, endpoint := range planned.endpoints { + if endpoint.SSE != nil && endpoint.Method.ViewedResult != nil && endpoint.Method.ViewedResult.ViewName == "" { + imports = append(imports, codegen.GoaImport("")) + break + } + } sections := []*codegen.SectionTemplate{ codegen.Header(title, "server", imports), { Name: "jsonrpc-sse-server-stream-base", Source: jsonrpcTemplates.Read(sseServerStreamBaseT), + Data: &sseServerTemplateData{ + Stream: planned.serverNames.sseStream, + Buffer: planned.serverNames.sseBuffer, + }, }, } - for _, ed := range data.Endpoints { + funcs := viewedResultFuncs(planned) + funcs["sseStreamName"] = planned.sseStreamName + for _, ed := range planned.endpoints { if ed.SSE == nil { continue } sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-sse-server-stream", - Source: jsonrpcTemplates.Read(sseServerStreamT), - Data: ed, + Name: "jsonrpc-sse-server-stream", + Source: jsonrpcTemplates.Read(sseServerStreamT), + Data: ed, + FuncMap: funcs, }) } return &codegen.File{Path: path, SectionTemplates: sections} } -// sseClientFile returns the file implementing the SSE client streaming implementation if any. -func sseClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) - if data == nil { - return nil - } - if !hasSSEEndpoint(data) { +// sseClientFile returns the server-sent-event client file when the service has +// a method that receives events. +func sseClientFile(planned *servicePlan) *codegen.File { + data := planned.data + if !planned.hasSSE { return nil } path := filepath.Join(codegen.Gendir, "jsonrpc", data.Service.PathName, "client", "stream.go") - tmplSections := sseClientStreamSections(data) + tmplSections := sseClientStreamSections(planned) sections := make([]*codegen.SectionTemplate, 0, 1+len(tmplSections)) sections = append(sections, codegen.Header( @@ -78,6 +95,7 @@ func sseClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodeg {Path: "bytes"}, {Path: "context"}, {Path: "encoding/json"}, + {Path: "errors"}, {Path: "fmt"}, {Path: "io"}, {Path: "net/http"}, @@ -85,7 +103,7 @@ func sseClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodeg {Path: "sync"}, codegen.GoaImport("jsonrpc"), codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + data.Service.PathName, Name: data.Service.PkgName}, + data.ClientServiceImport(), }, ), ) @@ -93,29 +111,21 @@ func sseClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodeg return &codegen.File{Path: path, SectionTemplates: sections} } -// sseClientStreamSections returns section templates for SSE client endpoints. -func sseClientStreamSections(data *httpcodegen.ServiceData) []*codegen.SectionTemplate { +// sseClientStreamSections returns the generated code for each method that +// receives server-sent events. +func sseClientStreamSections(service *servicePlan) []*codegen.SectionTemplate { sections := make([]*codegen.SectionTemplate, 0) - for _, ed := range data.Endpoints { + for _, ed := range service.endpoints { if ed.SSE == nil { continue } - // Generate SSE client stream struct and methods + // Write the client stream type and its methods. sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-sse-client-stream", - Source: jsonrpcTemplates.Read(sseClientStreamT), - Data: ed, + Name: "jsonrpc-sse-client-stream", + Source: jsonrpcTemplates.Read(sseClientStreamT), + Data: ed, + FuncMap: viewedResultFuncs(service), }) } return sections } - -// hasSSEEndpoint returns true if any endpoint of the service uses SSE. -func hasSSEEndpoint(data *httpcodegen.ServiceData) bool { - for _, ed := range data.Endpoints { - if ed.SSE != nil { - return true - } - } - return false -} diff --git a/jsonrpc/codegen/sse_dedup_test.go b/jsonrpc/codegen/sse_dedup_test.go index 503aae83c6..4b360caea7 100644 --- a/jsonrpc/codegen/sse_dedup_test.go +++ b/jsonrpc/codegen/sse_dedup_test.go @@ -17,10 +17,10 @@ import ( // endpoint. func TestJSONRPCSSE_DedupEventTypes(t *testing.T) { root := expr.RunDSL(t, testdata.JSONRPCSSEDuplicateEventDSL) - services := CreateJSONRPCServices(root) + plan := CreateJSONRPCPlan(root) // Generate JSON-RPC server files (includes the SSE streams file) - fs := ServerFiles("", services) + fs := plan.ServerFiles() require.NotEmpty(t, fs) // Render the SSE streams file (sse.go) @@ -40,8 +40,8 @@ func TestJSONRPCSSE_DedupEventTypes(t *testing.T) { require.NotEmpty(t, code, "sse.go content not found") // The shared machinery must be declared exactly once. - require.Equal(t, 1, strings.Count(code, "type sseServerStream struct"), "expected a single sseServerStream declaration\n%s", code) - require.Equal(t, 1, strings.Count(code, "type sseEventWriter struct"), "expected a single sseEventWriter declaration\n%s", code) + require.Equal(t, 1, strings.Count(code, "sseServerStream struct"), "expected a single sseServerStream declaration\n%s", code) + require.Equal(t, 1, strings.Count(code, "sseEventBuffer struct"), "expected a single sseEventBuffer declaration\n%s", code) // Each endpoint gets its own stream type even when sharing the event type. require.Equal(t, 1, strings.Count(code, "type StreamAServerStream struct"), "expected a single StreamA stream declaration\n%s", code) diff --git a/jsonrpc/codegen/sse_integration_test.go b/jsonrpc/codegen/sse_integration_test.go index a16f91ab80..8958c42f26 100644 --- a/jsonrpc/codegen/sse_integration_test.go +++ b/jsonrpc/codegen/sse_integration_test.go @@ -20,11 +20,11 @@ func TestJSONRPCSSEIntegration(t *testing.T) { // Run the DSL root := expr.RunDSL(t, testdata.JSONRPCSSEObjectDSL) - services := CreateJSONRPCServices(root) + plan := CreateJSONRPCPlan(root) // Generate all files - serverFiles := ServerFiles("", services) - clientFiles := ClientFiles("", services) + serverFiles := plan.ServerFiles() + clientFiles := plan.ClientFiles() // Combine all files allFiles := make([]*codegen.File, 0, len(serverFiles)+len(clientFiles)) diff --git a/jsonrpc/codegen/sse_test.go b/jsonrpc/codegen/sse_test.go index 546d88e028..ef7c684ac9 100644 --- a/jsonrpc/codegen/sse_test.go +++ b/jsonrpc/codegen/sse_test.go @@ -24,10 +24,10 @@ func TestJSONRPCSSE(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateJSONRPCServices(root) + plan := CreateJSONRPCPlan(root) // Generate server files (includes the SSE streams file) - fs := ServerFiles("", services) + fs := plan.ServerFiles() require.NotEmpty(t, fs, "expected server files to be generated") // Debug: print all generated files diff --git a/jsonrpc/codegen/templates.go b/jsonrpc/codegen/templates.go index 0956843ea5..ad6393dd12 100644 --- a/jsonrpc/codegen/templates.go +++ b/jsonrpc/codegen/templates.go @@ -21,23 +21,13 @@ const ( mixedServerHandlerT = "mixed_server_handler" // Client - clientStructT = "client_struct" - clientInitT = "client_init" - clientEndpointInitT = "client_endpoint_init" - responseDecoderT = "response_decoder" - - // WebSocket templates - websocketServerStreamT = "websocket_server_stream" - websocketServerStreamWrapperT = "websocket_server_stream_wrapper" - websocketServerHandlerT = "websocket_server_handler" - websocketServerSendT = "websocket_server_send" - websocketServerRecvT = "websocket_server_recv" - websocketServerCloseT = "websocket_server_close" - - // JSON-RPC WebSocket client templates - websocketClientConnT = "websocket_client_conn" - websocketClientStreamT = "websocket_client_stream" - websocketStreamErrorTypesT = "websocket_stream_error_types" + clientStructT = "client_struct" + clientInitT = "client_init" + clientEndpointInitT = "client_endpoint_init" + responseDecoderT = "response_decoder" + viewedResultBodyDecodeT = "viewed_result_body_decode" + viewedResultDecodeT = "viewed_result_decode" + viewedResultEncodeT = "viewed_result_encode" // SSE templates sseServerStreamBaseT = "sse_server_stream_base" @@ -50,6 +40,8 @@ const ( queryTypeConversionP = "query_type_conversion" elementSliceConversionP = "element_slice_conversion" sliceItemConversionP = "slice_item_conversion" + headerConversionP = "header_conversion" + viewedResultMetadataP = "viewed_result_metadata" ) //go:embed templates/* diff --git a/jsonrpc/codegen/templates/client_endpoint_init.go.tpl b/jsonrpc/codegen/templates/client_endpoint_init.go.tpl index 9c21167db8..f253008f5b 100644 --- a/jsonrpc/codegen/templates/client_endpoint_init.go.tpl +++ b/jsonrpc/codegen/templates/client_endpoint_init.go.tpl @@ -1,64 +1,29 @@ -{{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) (not .Method.SkipRequestBodyEncodeDecode) (not (isWebSocketEndpoint .)) (not (isSSEEndpoint .)) }} +{{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) (not .Method.SkipRequestBodyEncodeDecode) (not (isSSEEndpoint .)) }} {{ printf "%s returns an endpoint that makes JSON-RPC requests to the %s service %s method." .EndpointInit .ServiceName .Method.Name | comment }} -func (c *{{ .ClientStruct }}) {{ .EndpointInit }}() goa.Endpoint { -{{- if not (isWebSocketEndpoint .) }} +func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}() goa.Endpoint { var ( - {{- if .RequestEncoder }} - encodeRequest = {{ .RequestEncoder }}(c.encoder) + {{- if .RequestEncoderDeclaration }} + encodeRequest = {{ .RequestEncoderDeclaration.Name }}(c.encoder) {{- end }} {{- if not (isSSEEndpoint .) }} - decodeResponse = {{ .ResponseDecoder }}(c.decoder, c.RestoreResponseBody) + decodeResponse = {{ .ResponseDecoderDeclaration.Name }}(c.decoder, c.RestoreResponseBody) {{- end }} ) -{{- end }} {{- if $retry }} endpoint := func(ctx context.Context, v any) (any, error) { {{- else }} return func(ctx context.Context, v any) (any, error) { {{- end }} -{{- if not (isWebSocketEndpoint .) }} - req, err := c.{{ .RequestInit.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }}) + req, err := c.{{ .RequestInit.Declaration.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }}) if err != nil { return nil, err } - {{- if .RequestEncoder }} + {{- if .RequestEncoderDeclaration }} if err := encodeRequest(req, v); err != nil { return nil, err } {{- end }} -{{- end }} -{{- if isWebSocketEndpoint . }} - {{- if and .ClientWebSocket.RecvName .ClientWebSocket.RecvTypeRef }} - // For WebSocket, pass the base decoder to the stream and decode inner results - decodeResponse := c.decoder - {{- end }} - - // Get direct WebSocket connection - ws, err := c.getConn(ctx) - if err != nil { - return nil, err - } - - // Create context with cancellation for the stream - streamCtx, cancel := context.WithCancel(ctx) - - // Create the stream with direct WebSocket handling - stream := &{{ .ClientWebSocket.VarName }}{ - ws: ws, - ctx: streamCtx, - cancel: cancel, - done: make(chan struct{}), - config: c.streamConfig, - {{- if and .ClientWebSocket.RecvName .ClientWebSocket.RecvTypeRef }} - decoder: decodeResponse, - {{- end }} - } - - // Start background response handler - go stream.responseHandler() - - return stream, nil -{{- else if isSSEEndpoint . }} +{{- if isSSEEndpoint . }} // For SSE endpoints, send JSON-RPC request and establish stream resp, err := c.Doer.Do(req) if err != nil { @@ -66,25 +31,25 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}() goa.Endpoint { } if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() + body, readErr := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } return nil, goahttp.ErrInvalidResponse("{{ .ServiceName }}", "{{ .Method.Name }}", resp.StatusCode, string(body)) } contentType := resp.Header.Get("Content-Type") if contentType != "" && !strings.HasPrefix(contentType, "text/event-stream") { - resp.Body.Close() - return nil, fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + contentTypeErr := fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + if err := resp.Body.Close(); err != nil { + return nil, errors.Join(contentTypeErr, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err)) + } + return nil, contentTypeErr } // Create the SSE client stream - stream := &{{ .Method.VarName }}ClientStream{ - resp: resp, - reader: bufio.NewReader(resp.Body), - decoder: c.decoder, - } - - return stream, nil + return {{ .SSE.ClientInitDeclaration.Name }}(resp, c.decoder), nil {{- else }} resp, err := c.Doer.Do(req) if err != nil { diff --git a/jsonrpc/codegen/templates/client_init.go.tpl b/jsonrpc/codegen/templates/client_init.go.tpl index 4fa24f350c..72405ff856 100644 --- a/jsonrpc/codegen/templates/client_init.go.tpl +++ b/jsonrpc/codegen/templates/client_init.go.tpl @@ -1,23 +1,13 @@ -{{ printf "New%s instantiates HTTP clients for all the %s service servers." .ClientStruct .Service.Name | comment }} -func New{{ .ClientStruct }}( +{{ printf "%s creates HTTP clients for all the %s service servers." .ClientInitDeclaration.Name .Service.Name | comment }} +func {{ .ClientInitDeclaration.Name }}( scheme string, host string, doer goahttp.Doer, enc func(*http.Request) goahttp.Encoder, dec func(*http.Response) goahttp.Decoder, restoreBody bool, - {{- if hasWebSocket . }} - dialer goahttp.Dialer, - cfn goahttp.ConnConfigureFunc, - streamOpts ...jsonrpc.StreamConfigOption, - {{- end }} -) *{{ .ClientStruct }} { - {{- if hasWebSocket . }} - // Create stream configuration from options - streamConfig := jsonrpc.NewStreamConfig(streamOpts...) - {{- end }} - - return &{{ .ClientStruct }}{ +) *{{ .ClientStructDeclaration.Name }} { + return &{{ .ClientStructDeclaration.Name }}{ Doer: doer, {{- range .Endpoints }} {{- if isSSEEndpoint . }} @@ -29,10 +19,5 @@ func New{{ .ClientStruct }}( host: host, decoder: dec, encoder: enc, - {{- if hasWebSocket . }} - dialer: dialer, - configfn: cfn, - streamConfig: streamConfig, - {{- end }} } } diff --git a/jsonrpc/codegen/templates/client_struct.go.tpl b/jsonrpc/codegen/templates/client_struct.go.tpl index 3670bf2ef0..067a01507f 100644 --- a/jsonrpc/codegen/templates/client_struct.go.tpl +++ b/jsonrpc/codegen/templates/client_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s lists the %s service endpoint HTTP clients." .ClientStruct .Service.Name | comment }} -type {{ .ClientStruct }} struct { +{{ printf "%s lists the %s service endpoint HTTP clients." .ClientStructDeclaration.Name .Service.Name | comment }} +type {{ .ClientStructDeclaration.Name }} struct { {{ printf "Doer is the HTTP client used to make requests to the %s service." .Service.Name | comment }} Doer goahttp.Doer {{- range .Endpoints }} @@ -16,21 +16,8 @@ type {{ .ClientStruct }} struct { host string encoder func(*http.Request) goahttp.Encoder decoder func(*http.Response) goahttp.Decoder - {{- if hasWebSocket . }} - dialer goahttp.Dialer - configfn goahttp.ConnConfigureFunc - - connMu sync.RWMutex - conn *websocket.Conn - closed atomic.Bool - - // Stream configuration (shared by all WebSocket streams) - streamConfig *jsonrpc.StreamConfig - {{- end }} } -{{- if not (hasWebSocket .) }} -// bufferPool is a pool of bytes.Buffers for encoding requests. -var bufferPool = sync.Pool{ +{{ printf "%s reuses byte buffers while requests are encoded." .BufferPool.Name | comment }} +var {{ .BufferPool.Name }} = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } -{{- end }} diff --git a/jsonrpc/codegen/templates/mixed_server_handler.go.tpl b/jsonrpc/codegen/templates/mixed_server_handler.go.tpl index 6390f0966e..d35286fe2f 100644 --- a/jsonrpc/codegen/templates/mixed_server_handler.go.tpl +++ b/jsonrpc/codegen/templates/mixed_server_handler.go.tpl @@ -1,13 +1,147 @@ -// ServeHTTP handles JSON-RPC requests with content negotiation for mixed HTTP/SSE transports. -func (s *{{ .ServerStruct }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Check Accept header for SSE - accept := r.Header.Get("Accept") - if strings.Contains(accept, "text/event-stream") { - // Route to SSE handler for streaming methods - s.handleSSE(w, r) +// ServeHTTP decodes one request and uses the response type designed for its method. +func (s *{{ .ServerStructDeclaration.Name }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { + acceptJSON := false + acceptSSE := false + acceptValues := r.Header.Values("Accept") + if len(acceptValues) == 0 || len(acceptValues) == 1 && strings.TrimSpace(acceptValues[0]) == "" { + acceptJSON = true + acceptSSE = true + } else { + for _, header := range acceptValues { + for _, value := range strings.Split(header, ",") { + mediaType, params, err := mime.ParseMediaType(value) + if err != nil { + continue + } + quality := 1.0 + if value, ok := params["q"]; ok { + quality, err = strconv.ParseFloat(value, 64) + if err != nil { + continue + } + } + if quality <= 0 { + continue + } + switch mediaType { + case "*/*": + acceptJSON = true + acceptSSE = true + case "application/json", "application/*": + acceptJSON = true + case "text/event-stream", "text/*": + acceptSSE = true + } + } + } + } + + originalBody := r.Body + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + } + r.Body = io.NopCloser(bufReader) + + // Request arrays always use ordinary JSON-RPC responses. Streaming methods + // in an array receive one method error and are not called. + if len(peek) > 0 && peek[0] == '[' { + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + if !acceptJSON { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.handleBatch(w, r) + return + } + + // Decode the request once so the generated method switch below can choose + // both the handler and its response type. + var req jsonrpc.RawRequest + if err := s.decoder(r).Decode(&req); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + switch { + case acceptJSON: + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) + if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) + } + case acceptSSE: + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if sendErr := stream.sendError(r.Context(), nil, jsonrpc.ParseError, "Parse error", nil); sendErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("write parse error event: %w", sendErr)) + } + default: + w.WriteHeader(http.StatusNotAcceptable) + } return } - - // Otherwise handle as regular JSON-RPC HTTP request - s.handleHTTP(w, r) + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + + // Invalid and unknown requests do not have a designed response type. Use + // JSON when the client accepts it, then events, or reject the response. + if req.Invalid || req.JSONRPC != "2.0" || req.Method == "" { + switch { + case acceptJSON: + s.processRequest(r.Context(), r, &req, w) + case acceptSSE: + s.processSSERequest(r.Context(), r, &req, w) + default: + w.WriteHeader(http.StatusNotAcceptable) + } + return + } + + switch req.Method { +{{- range .Endpoints }} + {{- if .SSE }} + case {{ printf "%q" .Method.Name }}: + if !acceptSSE { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.processSSERequest(r.Context(), r, &req, w) + {{- else }} + case {{ printf "%q" .Method.Name }}: + if !acceptJSON { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.processRequest(r.Context(), r, &req, w) + {{- end }} +{{- end }} + default: + switch { + case acceptJSON: + s.processRequest(r.Context(), r, &req, w) + case acceptSSE: + s.processSSERequest(r.Context(), r, &req, w) + default: + w.WriteHeader(http.StatusNotAcceptable) + } + } } diff --git a/jsonrpc/codegen/templates/partial/element_slice_conversion.go.tpl b/jsonrpc/codegen/templates/partial/element_slice_conversion.go.tpl index c9658f58c4..1e984ffd67 100644 --- a/jsonrpc/codegen/templates/partial/element_slice_conversion.go.tpl +++ b/jsonrpc/codegen/templates/partial/element_slice_conversion.go.tpl @@ -1,4 +1,4 @@ - {{ .VarName }} = make({{ goTypeRef .Type }}, len({{ .VarName }}Raw)) + {{ .VarName }} = make({{ .TypeRef }}, len({{ .VarName }}Raw)) for i, rv := range {{ .VarName }}Raw { {{- template "partial_slice_item_conversion" . }} } diff --git a/jsonrpc/codegen/templates/partial/header_conversion.go.tpl b/jsonrpc/codegen/templates/partial/header_conversion.go.tpl new file mode 100644 index 0000000000..a88b48fc2e --- /dev/null +++ b/jsonrpc/codegen/templates/partial/header_conversion.go.tpl @@ -0,0 +1,38 @@ + {{- if eq .TypeName "boolean" -}} + {{ .VarName }} := strconv.FormatBool({{ if not .Required }}*{{ end }}{{ .Target }}) + {{- else if eq .TypeName "int" -}} + {{ .VarName }} := strconv.Itoa({{ if not .Required }}*{{ end }}{{ .Target }}) + {{- else if eq .TypeName "int32" -}} + {{ .VarName }} := strconv.FormatInt(int64({{ if not .Required }}*{{ end }}{{ .Target }}), 10) + {{- else if eq .TypeName "int64" -}} + {{ .VarName }} := strconv.FormatInt({{ if not .Required }}*{{ end }}{{ .Target }}, 10) + {{- else if eq .TypeName "uint" -}} + {{ .VarName }} := strconv.FormatUint(uint64({{ if not .Required }}*{{ end }}{{ .Target }}), 10) + {{- else if eq .TypeName "uint32" -}} + {{ .VarName }} := strconv.FormatUint(uint64({{ if not .Required }}*{{ end }}{{ .Target }}), 10) + {{- else if eq .TypeName "uint64" -}} + {{ .VarName }} := strconv.FormatUint({{ if not .Required }}*{{ end }}{{ .Target }}, 10) + {{- else if eq .TypeName "float32" -}} + {{ .VarName }} := strconv.FormatFloat(float64({{ if not .Required }}*{{ end }}{{ .Target }}), 'f', -1, 32) + {{- else if eq .TypeName "float64" -}} + {{ .VarName }} := strconv.FormatFloat({{ if not .Required }}*{{ end }}{{ .Target }}, 'f', -1, 64) + {{- else if eq .TypeName "string" -}} + {{ .VarName }} := {{ .Target }} + {{- else if eq .TypeName "bytes" -}} + {{ .VarName }} := string({{ .Target }}) + {{- else if eq .TypeName "any" -}} + {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) + {{- else if eq .TypeName "array" -}} + {{- if eq .ElemTypeName "string" -}} + {{ .VarName }} := strings.Join({{ .Target }}, ", ") + {{- else -}} + {{ .VarName }}Slice := make([]string, len({{ .Target }})) + for i, e := range {{ .Target }} { + {{ template "partial_header_conversion" (headerConversionData .ElemTypeName "" "es" true "e") }} + {{ .VarName }}Slice[i] = es + } + {{ .VarName }} := strings.Join({{ .VarName }}Slice, ", ") + {{- end }} + {{- else }} + // The Goa design must use a primitive value or an array for an HTTP response header or cookie. + {{- end }} diff --git a/jsonrpc/codegen/templates/partial/query_type_conversion.go.tpl b/jsonrpc/codegen/templates/partial/query_type_conversion.go.tpl index 9765e1e141..e367786266 100644 --- a/jsonrpc/codegen/templates/partial/query_type_conversion.go.tpl +++ b/jsonrpc/codegen/templates/partial/query_type_conversion.go.tpl @@ -1,6 +1,6 @@ - {{- if eq .Type.Name "bytes" }} + {{- if eq .TypeName "bytes" }} {{ .VarName }} = []byte({{.VarName}}Raw) - {{- else if eq .Type.Name "int" }} + {{- else if eq .TypeName "int" }} v, err2 := strconv.ParseInt({{ .VarName }}Raw, 10, strconv.IntSize) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "integer")) @@ -11,7 +11,7 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}int{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "int32" }} + {{- else if eq .TypeName "int32" }} v, err2 := strconv.ParseInt({{ .VarName }}Raw, 10, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "integer")) @@ -22,13 +22,13 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}int32{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "int64" }} + {{- else if eq .TypeName "int64" }} v, err2 := strconv.ParseInt({{ .VarName }}Raw, 10, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "integer")) } {{ if and (ne .TypeRef nil) (and (ne .TypeRef "int64") (ne .TypeRef "*int64")) }}{{ .VarName }} = ({{.TypeRef}})({{ if .Pointer }}&{{ end }}v){{ else }}{{ .VarName }} = {{ if .Pointer }}&{{ end }}v{{ end }} - {{- else if eq .Type.Name "uint" }} + {{- else if eq .TypeName "uint" }} v, err2 := strconv.ParseUint({{ .VarName }}Raw, 10, strconv.IntSize) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "unsigned integer")) @@ -39,7 +39,7 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}uint{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "uint32" }} + {{- else if eq .TypeName "uint32" }} v, err2 := strconv.ParseUint({{ .VarName }}Raw, 10, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "unsigned integer")) @@ -50,13 +50,13 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}uint32{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "uint64" }} + {{- else if eq .TypeName "uint64" }} v, err2 := strconv.ParseUint({{ .VarName }}Raw, 10, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "unsigned integer")) } {{ if and (ne .TypeRef nil) (and (ne .TypeRef "uint64") (ne .TypeRef "*uint64")) }}{{ .VarName }} = ({{.TypeRef}})({{ if .Pointer }}&{{ end }}v){{ else }}{{ .VarName }} = {{ if .Pointer }}&{{ end }}v{{ end }} - {{- else if eq .Type.Name "float32" }} + {{- else if eq .TypeName "float32" }} v, err2 := strconv.ParseFloat({{ .VarName }}Raw, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "float")) @@ -67,18 +67,18 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}float32{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "float64" }} + {{- else if eq .TypeName "float64" }} v, err2 := strconv.ParseFloat({{ .VarName }}Raw, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "float")) } {{ if and (ne .TypeRef nil) (and (ne .TypeRef "float64") (ne .TypeRef "*float64")) }}{{ .VarName }} = ({{.TypeRef}})({{ if .Pointer }}&{{ end }}v){{ else }}{{ .VarName }} = {{ if .Pointer }}&{{ end }}v{{ end }} - {{- else if eq .Type.Name "boolean" }} + {{- else if eq .TypeName "boolean" }} v, err2 := strconv.ParseBool({{ .VarName }}Raw) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "boolean")) } {{ if and (ne .TypeRef nil) (and (ne .TypeRef "bool") (ne .TypeRef "*bool")) }}{{ .VarName }} = ({{.TypeRef}})({{ if .Pointer }}&{{ end }}v){{ else }}{{ .VarName }} = {{ if .Pointer }}&{{ end }}v{{ end }} {{- else }} - // unsupported type {{ .Type.Name }} for var {{ .VarName }} + // The Goa design must use bytes, a number, or a boolean for this HTTP response value. {{- end }} diff --git a/jsonrpc/codegen/templates/partial/single_response.go.tpl b/jsonrpc/codegen/templates/partial/single_response.go.tpl index 764446207d..e3554591eb 100644 --- a/jsonrpc/codegen/templates/partial/single_response.go.tpl +++ b/jsonrpc/codegen/templates/partial/single_response.go.tpl @@ -1,14 +1,19 @@ {{- with .Data }} {{- if .ClientBody }} var ( - body {{ .ClientBody.VarName }} + body {{ if .ClientBody.Declaration }}{{ .ClientBody.Declaration.Name }}{{ else }}{{ .ClientBody.VarName }}{{ end }} err error ) err = decoder(resp).Decode(&body) if err != nil { return nil, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) } - {{- if .ClientBody.ValidateRef }} + {{- if and .ClientBody.ValidatorDeclaration .ClientBody.ValidationTarget }} + err = {{ .ClientBody.ValidatorDeclaration.Name }}({{ .ClientBody.ValidationTarget }}) + if err != nil { + return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- else if .ClientBody.ValidateRef }} {{ .ClientBody.ValidateRef }} if err != nil { return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) @@ -29,19 +34,19 @@ ) {{- range .Headers }} - {{- if (or (eq .Type.Name "string") (eq .Type.Name "any")) }} + {{- if (or (eq .TypeName "string") (eq .TypeName "any")) }} {{ .VarName }}Raw := resp.Header.Get("{{ .CanonicalName }}") {{- if .Required }} if {{ .VarName }}Raw == "" { err = goa.MergeErrors(err, goa.MissingFieldError("{{ .Name }}", "header")) } - {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{ .VarName }} = {{ if and (eq .TypeName "string") .Pointer }}&{{ end }}{{ .VarName }}Raw {{- else }} if {{ .VarName }}Raw != "" { - {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{ .VarName }} = {{ if and (eq .TypeName "string") .Pointer }}&{{ end }}{{ .VarName }}Raw } {{- if .DefaultValue }} else { - {{ .VarName }} = {{ if eq .Type.Name "string" }}{{ printf "%q" .DefaultValue }}{{ else }}{{ printf "%#v" .DefaultValue }}{{ end }} + {{ .VarName }} = {{ if eq .TypeName "string" }}{{ printf "%q" .DefaultValue }}{{ else }}{{ printf "%#v" .DefaultValue }}{{ end }} } {{- end }} {{- end }} @@ -135,18 +140,18 @@ } {{- range .Cookies }} - {{- if (or (eq .Type.Name "string") (eq .Type.Name "any")) }} + {{- if (or (eq .TypeName "string") (eq .TypeName "any")) }} {{- if .Required }} if {{ .VarName }}Raw == "" { err = goa.MergeErrors(err, goa.MissingFieldError("{{ .Name }}", "cookie")) } - {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{ .VarName }} = {{ if and (eq .TypeName "string") .Pointer }}&{{ end }}{{ .VarName }}Raw {{- else }} if {{ .VarName }}Raw != "" { - {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{ .VarName }} = {{ if and (eq .TypeName "string") .Pointer }}&{{ end }}{{ .VarName }}Raw } {{- if .DefaultValue }} else { - {{ .VarName }} = {{ if eq .Type.Name "string" }}{{ printf "%q" .DefaultValue }}{{ else }}{{ printf "%#v" .DefaultValue }}{{ end }} + {{ .VarName }} = {{ if eq .TypeName "string" }}{{ printf "%q" .DefaultValue }}{{ else }}{{ printf "%#v" .DefaultValue }}{{ end }} } {{- end }} {{- end }} diff --git a/jsonrpc/codegen/templates/partial/slice_item_conversion.go.tpl b/jsonrpc/codegen/templates/partial/slice_item_conversion.go.tpl index ece0457571..3ab157de32 100644 --- a/jsonrpc/codegen/templates/partial/slice_item_conversion.go.tpl +++ b/jsonrpc/codegen/templates/partial/slice_item_conversion.go.tpl @@ -1,63 +1,63 @@ - {{- if eq .Type.ElemType.Type.Name "string" }} - {{ .VarName }}[i] = rv - {{- else if eq .Type.ElemType.Type.Name "bytes" }} - {{ .VarName }}[i] = []byte(rv) - {{- else if eq .Type.ElemType.Type.Name "int" }} + {{- if eq .ElemTypeName "string" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(rv) + {{- else if eq .ElemTypeName "bytes" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}([]byte(rv)) + {{- else if eq .ElemTypeName "int" }} v, err2 := strconv.ParseInt(rv, 10, strconv.IntSize) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of integers")) } - {{ .VarName }}[i] = int(v) - {{- else if eq .Type.ElemType.Type.Name "int32" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "int32" }} v, err2 := strconv.ParseInt(rv, 10, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of integers")) } - {{ .VarName }}[i] = int32(v) - {{- else if eq .Type.ElemType.Type.Name "int64" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "int64" }} v, err2 := strconv.ParseInt(rv, 10, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of integers")) } - {{ .VarName }}[i] = v - {{- else if eq .Type.ElemType.Type.Name "uint" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "uint" }} v, err2 := strconv.ParseUint(rv, 10, strconv.IntSize) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of unsigned integers")) } - {{ .VarName }}[i] = uint(v) - {{- else if eq .Type.ElemType.Type.Name "uint32" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "uint32" }} v, err2 := strconv.ParseUint(rv, 10, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of unsigned integers")) } - {{ .VarName }}[i] = uint32(v) - {{- else if eq .Type.ElemType.Type.Name "uint64" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "uint64" }} v, err2 := strconv.ParseUint(rv, 10, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of unsigned integers")) } - {{ .VarName }}[i] = v - {{- else if eq .Type.ElemType.Type.Name "float32" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "float32" }} v, err2 := strconv.ParseFloat(rv, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of floats")) } - {{ .VarName }}[i] = float32(v) - {{- else if eq .Type.ElemType.Type.Name "float64" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "float64" }} v, err2 := strconv.ParseFloat(rv, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of floats")) } - {{ .VarName }}[i] = v - {{- else if eq .Type.ElemType.Type.Name "boolean" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "boolean" }} v, err2 := strconv.ParseBool(rv) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of booleans")) } - {{ .VarName }}[i] = v - {{- else if eq .Type.ElemType.Type.Name "any" }} - {{ .VarName }}[i] = rv + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "any" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(rv) {{- else }} - // unsupported slice type {{ .Type.ElemType.Type.Name }} for var {{ .VarName }} + // The Goa design must use primitive array elements for this HTTP response value. {{- end }} diff --git a/jsonrpc/codegen/templates/partial/viewed_result_metadata.go.tpl b/jsonrpc/codegen/templates/partial/viewed_result_metadata.go.tpl new file mode 100644 index 0000000000..9eee4e7d10 --- /dev/null +++ b/jsonrpc/codegen/templates/partial/viewed_result_metadata.go.tpl @@ -0,0 +1,77 @@ +{{- range .Headers }} + {{- $hasDefault := and (or .FieldPointer .Slice) .DefaultValue }} + {{- $checkNil := or .FieldPointer .Slice (eq .TypeName "bytes") (eq .TypeName "any") $hasDefault }} + {{- if $checkNil }} + if res.Projected.{{ .FieldName }} != nil { + {{- end }} + {{- if and (eq .TypeName "string") (not .IsAliased) }} + w.Header().Set("{{ .CanonicalName }}", {{ if .FieldPointer }}*{{ end }}res.Projected.{{ .FieldName }}) + {{- else }} + {{- if not $checkNil }} + { + {{- end }} + {{- if .IsAliased }} + val := {{ goTypeRef .TypeName .ElemTypeName }}({{ if .FieldPointer }}*{{ end }}res.Projected.{{ .FieldName }}) + {{ template "partial_header_conversion" (headerConversionData .TypeName .ElemTypeName (printf "%ss" .VarName) true "val") }} + {{- else }} + val := res.Projected.{{ .FieldName }} + {{ template "partial_header_conversion" (headerConversionData .TypeName .ElemTypeName (printf "%ss" .VarName) (not .FieldPointer) "val") }} + {{- end }} + w.Header().Set("{{ .CanonicalName }}", {{ .VarName }}s) + {{- if not $checkNil }} + } + {{- end }} + {{- end }} + {{- if $hasDefault }} + } else { + w.Header().Set("{{ .CanonicalName }}", "{{ printValue .TypeName .ElemTypeName .DefaultValue }}") + {{- end }} + {{- if or $checkNil $hasDefault }} + } + {{- end }} +{{- end }} +{{- range .Cookies }} + {{- $hasDefault := and (or .FieldPointer .Slice) .DefaultValue }} + {{- $checkNil := or .FieldPointer .Slice (eq .TypeName "bytes") (eq .TypeName "any") $hasDefault }} + {{- if $checkNil }} + if res.Projected.{{ .FieldName }} != nil { + {{- end }} + {{- if eq .TypeName "string" }} + {{ .VarName }} := {{ if .FieldPointer }}*{{ end }}res.Projected.{{ .FieldName }} + {{- else if .IsAliased }} + {{ .VarName }}raw := {{ goTypeRef .TypeName .ElemTypeName }}({{ if .FieldPointer }}*{{ end }}res.Projected.{{ .FieldName }}) + {{ template "partial_header_conversion" (headerConversionData .TypeName .ElemTypeName (printf "%sraw" .VarName) true .VarName) }} + {{- else }} + {{ .VarName }}raw := res.Projected.{{ .FieldName }} + {{ template "partial_header_conversion" (headerConversionData .TypeName .ElemTypeName (printf "%sraw" .VarName) (not .FieldPointer) .VarName) }} + {{- end }} + {{- if $hasDefault }} + } else { + {{ .VarName }} := "{{ printValue .TypeName .ElemTypeName .DefaultValue }}" + {{- end }} + http.SetCookie(w, &http.Cookie{ + Name: {{ printf "%q" .HTTPName }}, + Value: {{ .VarName }}, + {{- if .MaxAge }} + MaxAge: {{ .MaxAge }}, + {{- end }} + {{- if .Path }} + Path: {{ printf "%q" .Path }}, + {{- end }} + {{- if .Domain }} + Domain: {{ printf "%q" .Domain }}, + {{- end }} + {{- if .Secure }} + Secure: true, + {{- end }} + {{- if .HTTPOnly }} + HttpOnly: true, + {{- end }} + {{- if .SameSite }} + SameSite: {{ .SameSite }}, + {{- end }} + }) + {{- if or $checkNil $hasDefault }} + } + {{- end }} +{{- end }} diff --git a/jsonrpc/codegen/templates/response_decoder.go.tpl b/jsonrpc/codegen/templates/response_decoder.go.tpl index f53b85a866..6a8ae0921c 100644 --- a/jsonrpc/codegen/templates/response_decoder.go.tpl +++ b/jsonrpc/codegen/templates/response_decoder.go.tpl @@ -1,20 +1,30 @@ -{{ printf "%s returns a decoder for responses returned by the %s service %s JSON-RPC method. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoder .ServiceName .Method.Name | comment }} -func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { +{{ printf "%s returns a decoder for responses returned by the %s service %s JSON-RPC method. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoderDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } return nil, goahttp.ErrInvalidResponse("{{ .ServiceName }}", "{{ .Method.Name }}", resp.StatusCode, string(body)) } @@ -32,7 +42,7 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Error.Data)) {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - return nil, {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + return nil, {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- else if .ClientBody }} return nil, body {{- else }} @@ -42,44 +52,18 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- end }} {{- end }} default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse({{ printf "%q" .ServiceName }}, {{ printf "%q" .Method.Name }}, resp.StatusCode, string(body)) + return nil, goahttp.ErrInvalidResponse({{ printf "%q" .ServiceName }}, {{ printf "%q" .Method.Name }}, resp.StatusCode, string(jresp.Error.Data)) } } -{{- with index .Result.Responses 0 }} + {{- if .Method.ViewedResult }} + return {{ viewedDecodeName .Method.Name }}(decoder, resp, jresp.Result) + {{- else }} +{{- with index .Result.Responses 0 }} resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - {{- if .ViewedResult }} - p := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) - {{- if .TagName }} - tmp := {{ printf "%q" .TagValue }} - p.{{ .TagName }} = &tmp - {{- end }} - {{- if $.Method.ViewedResult.ViewName }} - view := {{ printf "%q" $.Method.ViewedResult.ViewName }} - {{- else }} - view := resp.Header.Get("goa-view") - {{- end }} - vres := {{ if not $.Method.ViewedResult.IsCollection }}&{{ end }}{{ $.Method.ViewedResult.ViewsPkg}}.{{ $.Method.ViewedResult.VarName }}{Projected: p, View: view} - {{- if .ClientBody }} - if err = {{ $.Method.ViewedResult.ViewsPkg}}.Validate{{ $.Method.Result }}(vres); err != nil { - return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) - } - {{- end }} - res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Name }}(vres) - {{- else }} - res := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) - {{- end }} - {{- if and .TagName (not .ViewedResult) }} - {{- if .TagPointer }} - tmp := {{ printf "%q" .TagValue }} - res.{{ .TagName }} = &tmp - {{- else }} - res.{{ .TagName }} = {{ printf "%q" .TagValue }} - {{- end }} - {{- end }} + res := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) return res, nil {{- else if .ClientBody }} return body, nil @@ -91,5 +75,6 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor return nil, nil {{- end }} {{- end }} + {{- end }} } } diff --git a/jsonrpc/codegen/templates/server_encode_error.go.tpl b/jsonrpc/codegen/templates/server_encode_error.go.tpl index ecacfa13ab..dcc49cc409 100644 --- a/jsonrpc/codegen/templates/server_encode_error.go.tpl +++ b/jsonrpc/codegen/templates/server_encode_error.go.tpl @@ -1,10 +1,10 @@ -{{ printf "encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil ID gracefully)" | comment }} -func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { - encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) +{{ printf "encodeJSONRPCError writes one error, copying the request ID or using null when none is available." | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { + {{ .EncodeError.Name }}(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -{{ printf "encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil ID gracefully)" | comment }} -func encodeJSONRPCError( +{{ printf "%s writes one error, copying the request ID or using null when none is available." .EncodeError.Name | comment }} +func {{ .EncodeError.Name }}( ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, @@ -14,10 +14,8 @@ func encodeJSONRPCError( encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), ) { - if req.ID != nil { - response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) - if err := encoder(ctx, w).Encode(response); err != nil { - errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) - } + response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } } diff --git a/jsonrpc/codegen/templates/server_handler.go.tpl b/jsonrpc/codegen/templates/server_handler.go.tpl index 9f894b05a4..f49e159ba6 100644 --- a/jsonrpc/codegen/templates/server_handler.go.tpl +++ b/jsonrpc/codegen/templates/server_handler.go.tpl @@ -1,36 +1,45 @@ -{{- if and (not (isWebSocketEndpoint (index .Endpoints 0))) (not (hasMixedTransports)) }} +{{- if not (hasMixedTransports) }} // ServeHTTP handles JSON-RPC requests. -func (s *{{ .ServerStruct }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (s *{{ .ServerStructDeclaration.Name }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleHTTP(w, r) } -{{- end }} -{{- comment "handleHTTP handles JSON-RPC requests." }} -func (s *{{ .ServerStruct }}) handleHTTP(w http.ResponseWriter, r *http.Request) { - // Peek at the first byte to determine request type - bufReader := bufio.NewReader(r.Body) - peek, err := bufReader.Peek(1) - if err != nil && err != io.EOF { - r.Body.Close() - s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", err)) - return +{{ comment "handleHTTP reads one JSON-RPC request object or one array of requests." }} +func (s *{{ .ServerStructDeclaration.Name }}) handleHTTP(w http.ResponseWriter, r *http.Request) { + originalBody := r.Body + + // Find the first JSON byte so leading whitespace does not change whether the + // body is decoded as one request or an array. + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } } - // Wrap the buffered reader with the original closer - r.Body = struct { - io.Reader - io.Closer - }{ - Reader: bufReader, - Closer: r.Body, - } - defer func(r *http.Request) { - if err := r.Body.Close(); err != nil { + // The generated handler owns the original body. Decoders receive a wrapper + // whose Close method cannot close it a second time. + r.Body = io.NopCloser(bufReader) + defer func() { + if err := originalBody.Close(); err != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) } - }(r) + }() - // Route to appropriate handler + // A leading '[' starts an array of requests. if len(peek) > 0 && peek[0] == '[' { s.handleBatch(w, r) return @@ -38,11 +47,11 @@ func (s *{{ .ServerStruct }}) handleHTTP(w http.ResponseWriter, r *http.Request) s.handleSingle(w, r) } -// handleSingle handles a single JSON-RPC request. -func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { +// handleSingle decodes and runs one JSON-RPC request. +func (s *{{ .ServerStructDeclaration.Name }}) handleSingle(w http.ResponseWriter, r *http.Request) { var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // JSON-RPC parse error with null id and generic message + // A request that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) @@ -51,37 +60,48 @@ func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { } s.processRequest(r.Context(), r, &req, w) } +{{- end }} -// handleBatch handles a batch of JSON-RPC requests. -func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { +// handleBatch handles an array of JSON-RPC values and writes the required responses. +func (s *{{ .ServerStructDeclaration.Name }}) handleBatch(w http.ResponseWriter, r *http.Request) { var reqs []jsonrpc.RawRequest if err := s.decoder(r).Decode(&reqs); err != nil { - // JSON-RPC parse error for batch with null id and generic message + // An array that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) } return } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) + } + return + } - // Write responses + // Write every response into one JSON array. w.Header().Set("Content-Type", "application/json") - writer := &batchWriter{Writer: w} + writer := &{{ .BatchWriter.Name }}{Writer: w} for _, req := range reqs { - // Process the request with batch writer + // The writer inserts the array separators around each response. s.processRequest(r.Context(), r, &req, writer) } - // Close the batch array + // Write the closing bracket only when at least one request produced a response. if writer.written { - writer.Writer.Write([]byte{']'}) + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } } } -// ProcessRequest processes a single JSON-RPC request. -func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { - if req.JSONRPC != "2.0" { +// processRequest validates the JSON-RPC version and method, then calls the matching handler. +func (s *{{ .ServerStructDeclaration.Name }}) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { + if req.Invalid || req.JSONRPC != "2.0" { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) return } @@ -93,44 +113,55 @@ func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonr switch req.Method { {{- range .Endpoints }} + {{- if not .SSE }} case {{ printf "%q" .Method.Name }}: if err := s.{{ .Method.VarName }}(ctx, r, req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", {{ printf "%q" .Method.Name }}, err)) } + {{- else }} + case {{ printf "%q" .Method.Name }}: + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method is not available in a batch request", nil) + } + {{- end }} {{- end }} default: - s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } } } -// batchWriter is a helper type that implements http.ResponseWriter for writing multiple JSON-RPC responses -type batchWriter struct { +{{ printf "%s inserts JSON array separators around responses from one request array." .BatchWriter.Name | comment }} +type {{ .BatchWriter.Name }} struct { io.Writer header http.Header statusCode int written bool } -func (rb *batchWriter) Header() http.Header { +func (rb *{{ .BatchWriter.Name }}) Header() http.Header { if rb.header == nil { rb.header = make(http.Header) } return rb.header } -func (rb *batchWriter) WriteHeader(statusCode int) { +func (rb *{{ .BatchWriter.Name }}) WriteHeader(statusCode int) { if rb.written { return } rb.statusCode = statusCode } -func (rb *batchWriter) Write(data []byte) (int, error) { +func (rb *{{ .BatchWriter.Name }}) Write(data []byte) (int, error) { + separator := byte(',') if !rb.written { - rb.written = true - rb.Writer.Write([]byte{'['}) - } else { - rb.Writer.Write([]byte{','}) + separator = '[' + } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err } + rb.written = true return rb.Writer.Write(data) } diff --git a/jsonrpc/codegen/templates/server_handler_init.go.tpl b/jsonrpc/codegen/templates/server_handler_init.go.tpl index 14d6d1305f..fabf41bb96 100644 --- a/jsonrpc/codegen/templates/server_handler_init.go.tpl +++ b/jsonrpc/codegen/templates/server_handler_init.go.tpl @@ -3,39 +3,33 @@ func {{ .HandlerInit }}( endpoint goa.Endpoint, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, -{{- if not (isWebSocketEndpoint .) }} encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), -{{- end }} -) func(context.Context, *http.Request, *jsonrpc.RawRequest{{ if not (isWebSocketEndpoint .) }}, http.ResponseWriter{{ end }}) {{ if isWebSocketEndpoint . }}(any, error){{ else }}error{{ end }} { +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { {{- if and (not (isSSEEndpoint .)) .Payload.Ref }} - {{- if not (and (isWebSocketEndpoint .) .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4))) }} - decodeParams := {{ .RequestDecoder }}(mux, decoder) - {{- end }} + decodeParams := {{ .RequestDecoderDeclaration.Name }}(mux, decoder) {{- end }} - return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest{{ if not (isWebSocketEndpoint .) }}, w http.ResponseWriter{{ end }}) {{ if isWebSocketEndpoint . }}(any, error){{ else }}error{{ end }} { + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { ctx = context.WithValue(ctx, goa.MethodKey, {{ printf "%q" .Method.Name }}) ctx = context.WithValue(ctx, goa.ServiceKey, {{ printf "%q" .ServiceName }}) {{- if isSSEEndpoint . }} - // Initialize SSE stream early so decode errors can be sent as SSE error events - strm := &{{ .SSE.StructName }}{ - sseServerStream: sseServerStream{ + // Create the stream before decoding so request failures can be sent on the + // same HTTP response. + strm := &{{ .SSE.StructDeclaration.Name }}{ + {{ sseStreamName }}: {{ sseStreamName }}{ w: w, - r: r, encoder: encoder, }, - requestID: req.ID, } {{- if .Payload.Ref }} - decodeParams := {{ .RequestDecoder }}(mux, decoder) + decodeParams := {{ .RequestDecoderDeclaration.Name }}(mux, decoder) params, err := decodeParams(r, req) - if err != nil { - // Send error via SSE (JSON-RPC error event) to match SSE transport semantics - if req.ID != nil && req.ID != "" { - strm.SendError(ctx, jsonrpc.IDToString(req.ID), err) - } - return nil + if err != nil { + if req.HasID { + return strm.sendError(ctx, req.ID, jsonrpc.InvalidParams, err.Error(), nil) + } + return nil } {{- if .Payload.IDAttribute }} {{- if .Payload.IDAttributeRequired }} @@ -55,10 +49,10 @@ func {{ .HandlerInit }}( if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" { ctx = context.WithValue(ctx, "last-event-id", lastEventID) {{- if .Payload.Ref }} - {{- if .Payload.Request }} - {{- if eq .Payload.Request.PayloadType.Name "Object" }} + {{- if .SSE.RequestIDPointer }} + params.{{ .SSE.RequestIDField }} = &lastEventID + {{- else }} params.{{ .SSE.RequestIDField }} = lastEventID - {{- end }} {{- end }} {{- end }} } @@ -68,39 +62,54 @@ func {{ .HandlerInit }}( {{- if .Payload.Ref }} Payload: params, {{- end }} - } - if _, err := endpoint(ctx, v); err != nil { - // Send the error as a JSON-RPC error event; SendError applies the - // design-driven error code mapping. - if req.ID != nil && req.ID != "" { - return strm.SendError(ctx, jsonrpc.IDToString(req.ID), err) - } - return nil } - return nil + {{- if .Payload.Ref }} + _, err = endpoint(ctx, v) + {{- else }} + _, err := endpoint(ctx, v) + {{- end }} + if err != nil { + if !req.HasID { + return nil + } + {{- if .Errors }} + var named goa.GoaErrorNamer + if errors.As(err, &named) { + switch named.GoaErrorName() { + {{- range $group := .Errors }} + {{- range $mapped := $group.Errors }} + case {{ printf "%q" $mapped.Name }}: + {{- with $mapped.Response }} + return strm.sendError(ctx, req.ID, {{ .Code }}, err.Error(), err) + {{- end }} + {{- end }} + {{- end }} + } + } + {{- end }} + return strm.sendError(ctx, req.ID, jsonrpc.InternalError, err.Error(), nil) + } + if !req.HasID { + return nil + } + + response := map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "result": nil, + } + return strm.sendSSEEvent(ctx, "response", response) {{- else }} {{- if .Payload.Ref }} - {{- if and (isWebSocketEndpoint .) .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - decodeParams := {{ .RequestDecoder }}(mux, decoder) - {{- end }} params, err := decodeParams(r, req) if err != nil { - {{- if isWebSocketEndpoint . }} - return nil, err - {{- else }} - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) + if req.HasID { + {{ encodeErrorName }}(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) } return nil - {{- end }} } {{- if .Payload.IDAttribute }} {{- if .Payload.IDAttributeRequired }} @@ -115,15 +124,6 @@ func {{ .HandlerInit }}( {{- end }} {{- end }} {{- end }} - {{- if and (isWebSocketEndpoint .) .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - // For {{ if eq .Method.ServerStream.Kind 3 }}server{{ else }}bidirectional{{ end }} streaming, we need to return the payload - // The actual streaming will be handled when the stream is passed to the endpoint - {{- if .Payload.Ref }} - return params, nil - {{- else }} - return nil, nil - {{- end }} - {{- else }} {{- if not .Result.Ref }} {{- if .Payload.Ref }} _, err = endpoint(ctx, params) @@ -133,54 +133,38 @@ func {{ .HandlerInit }}( {{- else }} res, err := endpoint(ctx, {{ if .Payload.Ref }}params{{ else }}nil{{ end }}) {{- end }} - {{- end }} - {{- if isWebSocketEndpoint . }} - {{- if not (and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4))) }} - return res, err - {{- end }} - {{- else }} if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { + if req.HasID { + {{- if .Errors }} var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { + if errors.As(err, &en) { + switch en.GoaErrorName() { {{- range $gerr := .Errors }} {{- range $err := $gerr.Errors }} - case {{ printf "%q" .Name }}: + case {{ printf "%q" .Name }}: {{- with .Response}} - encodeJSONRPCError(ctx, w, req, {{ .Code }}, err.Error(), err, encoder, errhandler) + {{ encodeErrorName }}(ctx, w, req, {{ .Code }}, err.Error(), err, encoder, errhandler) + return nil {{- end }} {{- end }} {{- end }} - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } + {{- end }} + {{ encodeErrorName }}(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil } - - // For methods with no result, check if this is a notification - {{- if not .Result.Ref }} - if req.ID == nil || req.ID == "" { - // Notification - no response + if !req.HasID { + // A notification has no ID field and receives no response. return nil } - // Request with no result - send empty success response + + {{- if not .Result.Ref }} + // A method with no result returns a JSON null result. response := jsonrpc.MakeSuccessResponse(req.ID, nil) if err := encoder(ctx, w).Encode(response); err != nil { errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) @@ -211,21 +195,21 @@ func {{ .HandlerInit }}( id = req.ID {{- end }} - if id == nil || id == "" { - // Notification - no response - return nil - } - // Send response with the result - {{- if and .Result.Ref (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - // Convert result to response body with proper JSON tags {{- if .Method.ViewedResult }} viewedRes := res.({{ .Method.ViewedResult.FullRef }}) - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(viewedRes.Projected) - {{- else }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(res.({{ .Result.Ref }})) + body, err := {{ viewedEncodeName .Method.Name }}(viewedRes) + if err != nil { + return err + } + {{- if viewedHasMetadata .Method.Name }} + {{ viewedMetadataName .Method.Name }}(w, viewedRes) {{- end }} response := jsonrpc.MakeSuccessResponse(id, body) + {{- else if and .Result.Ref (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} + // Build the response body with the fields and JSON names declared by the service. + body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Declaration.Name }}(res.({{ .Result.Ref }})) + response := jsonrpc.MakeSuccessResponse(id, body) {{- else }} response := jsonrpc.MakeSuccessResponse(id, res) {{- end }} @@ -234,7 +218,6 @@ func {{ .HandlerInit }}( } return nil {{- end }} - {{- end }} {{- end }} } } diff --git a/jsonrpc/codegen/templates/server_init.go.tpl b/jsonrpc/codegen/templates/server_init.go.tpl index 83e6896e32..02051ed7c3 100644 --- a/jsonrpc/codegen/templates/server_init.go.tpl +++ b/jsonrpc/codegen/templates/server_init.go.tpl @@ -1,54 +1,32 @@ -{{ printf "%s creates a JSON-RPC server which loads HTTP requests and calls the %q service methods." .ServerInit .Service.Name | comment }} -func {{ .ServerInit }}( -{{- if isWebSocketEndpoint (index .Endpoints 0) }} - streamHandler func(context.Context, {{ .Service.PkgName }}.Stream) error, -{{- end }} - endpoints *{{ .Service.PkgName }}.Endpoints, +{{ printf "%s creates a JSON-RPC server which loads HTTP requests and calls the %q service methods." .ServerInitDeclaration.Name .Service.Name | comment }} +func {{ .ServerInitDeclaration.Name }}( + endpoints *{{ .Service.PkgName }}.{{ .Service.EndpointsDeclaration.Name }}, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), - {{- if isWebSocketEndpoint (index .Endpoints 0) }} - upgrader goahttp.Upgrader, - configfn goahttp.ConnConfigureFunc, - {{- end }} -) *{{ .ServerStruct }} { - s := &{{ .ServerStruct }}{ +) *{{ .ServerStructDeclaration.Name }} { + s := &{{ .ServerStructDeclaration.Name }}{ Methods: []string{ {{- range .Endpoints }} {{ printf "%q" .Method.Name }}, {{- end }} }, -{{- if isWebSocketEndpoint (index .Endpoints 0) }} - StreamHandler: streamHandler, -{{- end }} {{- range .Endpoints }} - {{- if isWebSocketEndpoint . }} - {{ lowerInitial .Method.VarName }}: {{ .HandlerInit }}(endpoints.{{ .Method.VarName }}, mux, decoder), - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - {{ lowerInitial .Method.VarName }}Endpoint: endpoints.{{ .Method.VarName }}, - {{- end }} - {{- else }} {{ .Method.VarName }}: {{ .HandlerInit }}(endpoints.{{ .Method.VarName }}, mux, decoder, encoder, errhandler), - {{- end }} {{- end }} decoder: decoder, encoder: encoder, errhandler: errhandler, - {{- if isWebSocketEndpoint (index .Endpoints 0) }} - upgrader: upgrader, - configfn: configfn, - {{- end }} } - // Default HTTP handler per transport kind - {{- if isWebSocketEndpoint (index .Endpoints 0) }} - // WebSocket services implement ServeHTTP for upgrade + // Install the request handler required by this service's methods. + {{- if hasMixedTransports }} s.Handler = http.HandlerFunc(s.ServeHTTP) {{- else if isSSEEndpoint (index .Endpoints 0) }} - // SSE-only services route via handleSSE + // handleSSE writes each result as a server-sent event. s.Handler = http.HandlerFunc(s.handleSSE) {{- else }} - // Plain HTTP JSON-RPC + // ServeHTTP handles ordinary JSON-RPC request bodies. s.Handler = http.HandlerFunc(s.ServeHTTP) {{- end }} return s diff --git a/jsonrpc/codegen/templates/server_method_names.go.tpl b/jsonrpc/codegen/templates/server_method_names.go.tpl new file mode 100644 index 0000000000..d6a7ddc2aa --- /dev/null +++ b/jsonrpc/codegen/templates/server_method_names.go.tpl @@ -0,0 +1,2 @@ +{{ printf "MethodNames returns the methods served." | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) MethodNames() []string { return {{ .Service.PkgName }}.{{ .Service.MethodNamesDeclaration.Name }}[:] } diff --git a/jsonrpc/codegen/templates/server_mount.go.tpl b/jsonrpc/codegen/templates/server_mount.go.tpl index ecabd73a18..3bc32d2b08 100644 --- a/jsonrpc/codegen/templates/server_mount.go.tpl +++ b/jsonrpc/codegen/templates/server_mount.go.tpl @@ -1,26 +1,24 @@ -{{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServer .Service.Name | comment }} -func {{ .MountServer }}(mux goahttp.Muxer, h *{{ .ServerStruct }}) { +{{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServerDeclaration.Name .Service.Name | comment }} +func {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer, h *{{ .ServerStructDeclaration.Name }}) { {{- if .HasMixed }} - // Mixed transports: mount unified handler that negotiates HTTP vs SSE by Accept header + // ServeHTTP chooses ordinary JSON-RPC handling or server-sent events. {{- range (index .Endpoints 0).Routes }} mux.Handle("{{ .Verb }}", "{{ .Path }}", h.ServeHTTP) {{- end }} {{- else if .HasSSE }} - // SSE only: mount SSE handler - {{- range .Endpoints }} - {{- range .Routes }} + // This server handles every method through server-sent events. + {{- range (index .Endpoints 0).Routes }} mux.Handle("{{ .Verb }}", "{{ .Path }}", h.handleSSE) - {{- end }} {{- end }} {{- else }} - // HTTP only + // This server handles ordinary JSON-RPC request bodies. {{- range (index .Endpoints 0).Routes }} mux.Handle("{{ .Verb }}", "{{ .Path }}", h.ServeHTTP) {{- end }} {{- end }} } -{{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServer .Service.Name | comment }} -func (s *{{ .ServerStruct }}) {{ .MountServer }}(mux goahttp.Muxer) { - {{ .MountServer }}(mux, s) +{{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServerDeclaration.Name .Service.Name | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer) { + {{ .MountServerDeclaration.Name }}(mux, s) } diff --git a/jsonrpc/codegen/templates/server_service.go.tpl b/jsonrpc/codegen/templates/server_service.go.tpl new file mode 100644 index 0000000000..c8337b8caa --- /dev/null +++ b/jsonrpc/codegen/templates/server_service.go.tpl @@ -0,0 +1,2 @@ +{{ printf "%s returns the name of the service served." .ServerService | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) {{ .ServerService }}() string { return "{{ .Service.Name }}" } diff --git a/jsonrpc/codegen/templates/server_struct.go.tpl b/jsonrpc/codegen/templates/server_struct.go.tpl index eb3325b737..ca10934849 100644 --- a/jsonrpc/codegen/templates/server_struct.go.tpl +++ b/jsonrpc/codegen/templates/server_struct.go.tpl @@ -1,29 +1,14 @@ -{{ printf "%s handles JSON-RPC requests for the %s service." .ServerStruct .Service.Name | comment }} -type {{ .ServerStruct }} struct { +{{ printf "%s handles JSON-RPC requests for the %s service." .ServerStructDeclaration.Name .Service.Name | comment }} +type {{ .ServerStructDeclaration.Name }} struct { http.Handler // Methods is the list of methods served by this server. Methods []string -{{- if isWebSocketEndpoint (index .Endpoints 0) }} - // StreamHandler is the handler for the streaming service. - StreamHandler func(context.Context, {{ .Service.PkgName }}.Stream) error -{{- end }} {{ range .Endpoints }} - {{- if isWebSocketEndpoint . }} - {{ lowerInitial .Method.VarName }} func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - {{ lowerInitial .Method.VarName }}Endpoint goa.Endpoint - {{- end }} - {{- else }} {{ printf "%s is the handler for the %s method." .Method.VarName .Method.Name | comment }} {{ .Method.VarName }} func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error - {{- end }} {{- end }} decoder func(*http.Request) goahttp.Decoder encoder func(context.Context, http.ResponseWriter) goahttp.Encoder errhandler func(context.Context, http.ResponseWriter, error) -{{- if isWebSocketEndpoint (index .Endpoints 0) }} - upgrader goahttp.Upgrader - configfn goahttp.ConnConfigureFunc -{{- end }} } diff --git a/jsonrpc/codegen/templates/server_use.go.tpl b/jsonrpc/codegen/templates/server_use.go.tpl index 384309dd77..0e70742086 100644 --- a/jsonrpc/codegen/templates/server_use.go.tpl +++ b/jsonrpc/codegen/templates/server_use.go.tpl @@ -1,4 +1,4 @@ {{ printf "Use wraps the server handlers with the given middleware." | comment }} -func (s *{{ .ServerStruct }}) Use(m func(http.Handler) http.Handler) { +func (s *{{ .ServerStructDeclaration.Name }}) Use(m func(http.Handler) http.Handler) { s.Handler = m(s.Handler) } diff --git a/jsonrpc/codegen/templates/sse_client_stream.go.tpl b/jsonrpc/codegen/templates/sse_client_stream.go.tpl index a9f80fbe9c..e32e198b0f 100644 --- a/jsonrpc/codegen/templates/sse_client_stream.go.tpl +++ b/jsonrpc/codegen/templates/sse_client_stream.go.tpl @@ -1,14 +1,58 @@ -{{ printf "%sClientStream implements the %s.%sClientStream interface using Server-Sent Events." .Method.VarName .ServicePkgName .Method.VarName | comment }} -type {{ .Method.VarName }}ClientStream struct { - resp *http.Response // HTTP response object - reader *bufio.Reader // Buffered reader for SSE parsing - decoder func(*http.Response) goahttp.Decoder // User-provided decoder - closed bool // Whether the stream has been closed - lock sync.Mutex // Mutex to protect state +type ( + {{ printf "%s reads results sent as server-sent events." .SSE.ClientInterfaceDeclaration.Name | comment }} + {{ .SSE.ClientInterfaceDeclaration.Name }} interface { + {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) + {{ .Method.ClientStream.RecvWithContextName }}(context.Context) ({{ .SSE.EventTypeRef }}, error) + Close() error + } + + {{ printf "%s reads and decodes events for %s." .SSE.ClientStructDeclaration.Name .Method.Name | comment }} + {{ .SSE.ClientStructDeclaration.Name }} struct { + // resp is the open server response. + resp *http.Response + // reader reads one line at a time from resp. + reader *bufio.Reader + // decoder converts each result into its service type. + decoder func(*http.Response) goahttp.Decoder + // closed records whether Close was called or the response ended. + closed bool + // closeOnce ensures the response body is closed only once. + closeOnce sync.Once + // closeErr stores the response body close error. + closeErr error + // lock prevents two calls from reading or closing the response at once. + lock sync.Mutex + } +) + +{{ printf "%s creates a stream that reads server-sent events from resp." .SSE.ClientInitDeclaration.Name | comment }} +func {{ .SSE.ClientInitDeclaration.Name }}(resp *http.Response, decoder func(*http.Response) goahttp.Decoder) {{ .SSE.ClientInterfaceDeclaration.Name }} { + return &{{ .SSE.ClientStructDeclaration.Name }}{ + resp: resp, + reader: bufio.NewReader(resp.Body), + decoder: decoder, + } } -// parseSSEEvent parses a single SSE event from the stream -func (s *{{ .Method.VarName }}ClientStream) parseSSEEvent() (eventType string, data []byte, err error) { +// parseSSEEvent reads one complete event from the response. Ending ctx closes +// the response body so a blocked read returns. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) parseSSEEvent(ctx context.Context) (eventType string, data []byte, err error) { + closeResult := make(chan struct{}, 1) + stopClose := context.AfterFunc(ctx, func() { + s.closeBody() + closeResult <- struct{}{} + }) + defer func() { + if stopClose() { + return + } + <-closeResult + if contextErr := ctx.Err(); contextErr != nil { + eventType = "" + data = nil + err = contextErr + } + }() var event strings.Builder var dataLines []string @@ -16,7 +60,7 @@ func (s *{{ .Method.VarName }}ClientStream) parseSSEEvent() (eventType string, d line, err := s.reader.ReadString('\n') if err != nil { if err == io.EOF && len(dataLines) > 0 { - // Process final event + // Return the last event even when the response has no final blank line. break } return "", nil, err @@ -26,7 +70,7 @@ func (s *{{ .Method.VarName }}ClientStream) parseSSEEvent() (eventType string, d line = strings.TrimSuffix(line, "\r") if line == "" { - // Empty line marks end of event + // A blank line ends the current event. if len(dataLines) > 0 { break } @@ -38,7 +82,7 @@ func (s *{{ .Method.VarName }}ClientStream) parseSSEEvent() (eventType string, d } else if strings.HasPrefix(line, "data:") { dataLines = append(dataLines, strings.TrimSpace(line[5:])) } - // Ignore other fields like id:, retry: + // This client does not use the id and retry fields. } if len(dataLines) > 0 { @@ -49,142 +93,133 @@ func (s *{{ .Method.VarName }}ClientStream) parseSSEEvent() (eventType string, d } {{ comment .Method.ClientStream.RecvDesc }} -func (s *{{ .Method.VarName }}ClientStream) {{ .Method.ClientStream.RecvName }}(ctx context.Context) ({{ .Result.Ref }}, error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) { + return s.{{ .Method.ClientStream.RecvWithContextName }}(context.Background()) +} + +{{ comment .Method.ClientStream.RecvWithContextDesc }} +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvWithContextName }}(ctx context.Context) ({{ .SSE.EventTypeRef }}, error) { s.lock.Lock() defer s.lock.Unlock() - var zero {{ .Result.Ref }} + var zero {{ .SSE.EventTypeRef }} if s.closed { return zero, io.EOF } for { - eventType, data, err := s.parseSSEEvent() + eventType, data, err := s.parseSSEEvent(ctx) if err != nil { - s.closed = true - return zero, err + return zero, s.endStream(err) } switch eventType { case "notification": - // Parse JSON-RPC notification + // Read the streamed service result from the notification parameters. var notification struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` Params json.RawMessage `json:"params"` } if err := json.Unmarshal(data, ¬ification); err != nil { - return zero, fmt.Errorf("failed to parse notification: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse notification: %w", err)) } - // Validate notification if notification.JSONRPC != "2.0" { - return zero, fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC) + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC)) } if notification.Method != {{ printf "%q" .Method.Name }} { - // Skip notifications for other methods - continue + return zero, s.endStream(fmt.Errorf("received notification for JSON-RPC method %q", notification.Method)) } - // Decode the result from params - {{- if .Method.Result }} result, err := s.decodeResult(notification.Params) if err != nil { - return zero, fmt.Errorf("failed to decode result: %w", err) + return zero, s.endStream(fmt.Errorf("failed to decode result: %w", err)) } return result, nil - {{- else }} - // Method has no result - return zero, nil - {{- end }} case "response": - // Final response - parse and return + // A successful response completes the stream. Stream values arrive in + // the notifications handled above. var response jsonrpc.Response if err := json.Unmarshal(data, &response); err != nil { - return zero, fmt.Errorf("failed to parse response: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse response: %w", err)) } if response.Error != nil { - return zero, response.Error + return zero, s.endStream(response.Error) } - - {{- if .Method.Result }} - // Decode the final result - if response.Result == nil { - return zero, fmt.Errorf("missing result in response") - } - // Convert response.Result to json.RawMessage - resultBytes, err := json.Marshal(response.Result) - if err != nil { - return zero, fmt.Errorf("failed to marshal result: %w", err) - } - result, err := s.decodeResult(json.RawMessage(resultBytes)) - if err != nil { - return zero, fmt.Errorf("failed to decode final result: %w", err) - } - - // Mark stream as closed after final response - s.closed = true - return result, nil - {{- else }} - // Method has no result - s.closed = true - return zero, nil - {{- end }} + return zero, s.endStream(io.EOF) case "error": - // Error response + // A JSON-RPC error completes the stream. var response jsonrpc.Response if err := json.Unmarshal(data, &response); err != nil { - return zero, fmt.Errorf("failed to parse error response: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse error response: %w", err)) } - - s.closed = true if response.Error != nil { - return zero, response.Error + return zero, s.endStream(response.Error) } - return zero, fmt.Errorf("unexpected error response") + return zero, s.endStream(fmt.Errorf("JSON-RPC error event did not contain an error")) default: - // Ignore unknown event types - continue + return zero, s.endStream(fmt.Errorf("unsupported server-sent event type %q", eventType)) } } } -{{- if .Method.Result }} -// decodeResult decodes JSON-RPC result data using the user-provided decoder -func (s *{{ .Method.VarName }}ClientStream) decodeResult(data json.RawMessage) ({{ .Result.Ref }}, error) { - // Create minimal HTTP response with raw JSON data for user's decoder +// closeBody closes the HTTP response body once and returns its close error. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) closeBody() error { + s.closeOnce.Do(func() { + s.closeErr = s.resp.Body.Close() + }) + return s.closeErr +} + +// endStream marks the stream closed and preserves both the receive error and +// any error returned while closing the HTTP response body. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) endStream(err error) error { + s.closed = true + if closeErr := s.closeBody(); closeErr != nil { + return errors.Join(err, closeErr) + } + return err +} + +// decodeResult passes one successful stream item to the decoder configured by NewClient. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) decodeResult(data json.RawMessage) ({{ .SSE.EventTypeRef }}, error) { + {{- if .Method.ViewedResult }} + // The HTTP 200 status tells the configured decoder that this stream item is + // a successful JSON-RPC result. Streaming results cannot carry HTTP headers or cookies. + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)} + return {{ viewedDecodeName .Method.Name }}(s.decoder, resp, data) + {{- else }} + // Give the configured decoder the successful result bytes as an HTTP response body. resp := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(data)), } - // Use the user-provided decoder to decode the result decoder := s.decoder(resp) - var result {{ .Result.Ref }} + var result {{ .SSE.EventTypeRef }} if err := decoder.Decode(&result); err != nil { return result, err } return result, nil + {{- end }} } -{{- end }} {{ comment "Close closes the stream." }} -func (s *{{ .Method.VarName }}ClientStream) Close() error { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) Close() error { s.lock.Lock() defer s.lock.Unlock() - if !s.closed { - s.closed = true - if s.resp != nil && s.resp.Body != nil { - return s.resp.Body.Close() - } - } + if !s.closed { + s.closed = true + return s.closeBody() + } return nil } diff --git a/jsonrpc/codegen/templates/sse_server_handler.go.tpl b/jsonrpc/codegen/templates/sse_server_handler.go.tpl index 03ef92f715..ae710d7bbf 100644 --- a/jsonrpc/codegen/templates/sse_server_handler.go.tpl +++ b/jsonrpc/codegen/templates/sse_server_handler.go.tpl @@ -1,30 +1,52 @@ -// handleSSE handles JSON-RPC SSE requests by dispatching to the appropriate method. -func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { +{{- if not (hasMixedTransports) }} +// handleSSE finds the requested method and writes its results as server-sent events. +func (s *{{ .ServerStructDeclaration.Name }}) handleSSE(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + originalBody := r.Body + r.Body = io.NopCloser(originalBody) - // Read the JSON-RPC request + // Read the JSON-RPC request. var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // Emit JSON-RPC parse error as SSE event - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil) + closeErr := originalBody.Close() + s.errhandler(ctx, w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + // Write the parse error as a server-sent event. + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write parse error event: %w", err)) + } return } + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(ctx, w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + s.processSSERequest(ctx, r, &req, w) +} +{{- end }} + +// processSSERequest validates and runs one server-sent-event request. +func (s *{{ .ServerStructDeclaration.Name }}) processSSERequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { - // Validate JSON-RPC request - if req.JSONRPC != "2.0" { - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) + // Reject requests that do not use JSON-RPC 2.0. + if req.Invalid || req.JSONRPC != "2.0" { + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } return } if req.Method == "" { - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } return } - // Find the appropriate handler based on method name + // Find the function for the requested method. var handler func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error switch req.Method { {{- range .Endpoints }} @@ -32,28 +54,20 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { case {{ printf "%q" .Method.Name }}: handler = s.{{ .Method.VarName }} {{- end }} -{{- end }} + {{- end }} default: - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil) + if !req.HasID { + return + } + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write method not found event: %w", err)) + } return } - // Call the handler for the specific method - if err := handler(ctx, r, &req, w); err != nil { + // Call the requested method. + if err := handler(ctx, r, req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", req.Method, err)) - return - } - - // For notifications (requests without ID) that don't stream, return 204 No Content - switch req.Method { -{{- range .Endpoints }} - {{- if and .SSE (not .Method.ServerStream) }} - case {{ printf "%q" .Method.Name }}: - if req.ID == nil { - w.WriteHeader(http.StatusNoContent) - } - {{- end }} -{{- end }} } -} \ No newline at end of file +} diff --git a/jsonrpc/codegen/templates/sse_server_stream.go.tpl b/jsonrpc/codegen/templates/sse_server_stream.go.tpl index a61f6324ae..1580513e70 100644 --- a/jsonrpc/codegen/templates/sse_server_stream.go.tpl +++ b/jsonrpc/codegen/templates/sse_server_stream.go.tpl @@ -1,138 +1,63 @@ -{{ comment (printf "%s implements the %s.%s interface using Server-Sent Events." .SSE.StructName .ServicePkgName .Method.ServerStream.Interface) }} -type {{ .SSE.StructName }} struct { - // sseServerStream provides the shared SSE event encoding machinery - sseServerStream - // requestID is the JSON-RPC request ID for sending final response - requestID any - // closed indicates if the stream has been closed via SendAndClose - closed bool - // mu protects the closed flag - mu sync.Mutex -} - -{{ comment "Send sends a JSON-RPC notification to the client." }} -{{ comment "Notifications do not expect a response from the client." }} -func (s *{{ .SSE.StructName }}) Send(ctx context.Context, event {{ .ServicePkgName }}.{{ .Method.VarName }}Event) error { - {{ comment "Check if stream is closed" }} - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream closed") - } - s.mu.Unlock() - - {{ comment "Type assert to the specific result type" }} - result, ok := event.({{ .SSE.EventTypeRef }}) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - - {{- if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - {{ comment "Convert to response body type for proper JSON encoding" }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) - {{- else }} - body := result +{{ comment (printf "%s implements the %s.%s interface using Server-Sent Events." .SSE.StructDeclaration.Name .ServicePkgName .Method.ServerStream.Interface) }} +type {{ .SSE.StructDeclaration.Name }} struct { + // {{ sseStreamName }} writes JSON-RPC messages as server-sent events. + {{ sseStreamName }} + {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} + // view is the result view used to encode later stream values. + view string + {{ comment "sentView is the result view used by the first event. Later sends must use the same view." }} + sentView string {{- end }} +} - {{ comment "Send as notification (no ID)" }} - message := map[string]any{ - "jsonrpc": "2.0", - "method": {{ printf "%q" .Method.Name }}, - "params": body, - } +{{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} +{{ comment "SetView selects the result view used by later stream values." }} +func (s *{{ .SSE.StructDeclaration.Name }}) SetView(view string) { + s.view = view +} +{{- end }} - return s.sendSSEEvent("notification", message) +{{ comment .Method.ServerStream.SendDesc }} +func (s *{{ .SSE.StructDeclaration.Name }}) {{ .Method.ServerStream.SendName }}(event {{ .SSE.EventTypeRef }}) error { + return s.{{ .Method.ServerStream.SendWithContextName }}(context.Background(), event) } -{{ comment "SendAndClose sends a final JSON-RPC response to the client and closes the stream." }} -{{ comment "The response will include the original request ID unless the result has an ID field populated." }} -{{ comment "After calling this method, no more events can be sent on this stream." }} -func (s *{{ .SSE.StructName }}) SendAndClose(ctx context.Context, event {{ .ServicePkgName }}.{{ .Method.VarName }}Event) error { - {{ comment "Check if stream is already closed" }} - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream already closed") - } - s.closed = true - s.mu.Unlock() +{{ comment .Method.ServerStream.SendWithContextDesc }} +func (s *{{ .SSE.StructDeclaration.Name }}) {{ .Method.ServerStream.SendWithContextName }}(ctx context.Context, event {{ .SSE.EventTypeRef }}) error { + result := event - {{ comment "Type assert to the specific result type" }} - result, ok := event.({{ .SSE.EventTypeRef }}) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) + {{- if .Method.ViewedResult }} + {{- if not .Method.ViewedResult.ViewName }} + view := s.view + if view == "" { + view = "default" } - - {{ comment "Determine the ID to use for the response" }} - var id any = s.requestID - {{- if .Result.IDAttribute }} - {{- if .Result.IDAttributeRequired }} - if result.{{ .Result.IDAttribute }} != "" { - {{ comment "Use the ID from the result if provided" }} - id = result.{{ .Result.IDAttribute }} - {{ comment "Clear the ID field so it's not duplicated in the result" }} - result.{{ .Result.IDAttribute }} = "" + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) } - {{- else }} - if result.{{ .Result.IDAttribute }} != nil && *result.{{ .Result.IDAttribute }} != "" { - {{ comment "Use the ID from the result if provided" }} - id = *result.{{ .Result.IDAttribute }} - {{ comment "Clear the ID field so it's not duplicated in the result" }} - result.{{ .Result.IDAttribute }} = nil + {{- end }} + body, err := {{ viewedStreamEncodeName .Method.Name }}(result{{ if not .Method.ViewedResult.ViewName }}, view{{ end }}) + if err != nil { + return err } - {{- end }} + {{- if not .Method.ViewedResult.ViewName }} + s.sentView = view {{- end }} - - {{- if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - {{ comment "Convert to response body type for proper JSON encoding" }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) + {{- else if and .SSE.HasResponseBody .SSE.Response (index .SSE.Response.ServerBody 0).Init }} + body := {{ (index .SSE.Response.ServerBody 0).Init.Declaration.Name }}(result) {{- else }} body := result {{- end }} - {{ comment "Send as response with ID" }} message := map[string]any{ "jsonrpc": "2.0", - "id": id, - "result": body, + "method": {{ printf "%q" .Method.Name }}, + "params": body, } - - return s.sendSSEEvent("response", message) + return s.sendSSEEvent(ctx, "notification", message) } -{{ comment "SendError sends a JSON-RPC error response." }} -func (s *{{ .SSE.StructName }}) SendError(ctx context.Context, id string, err error) error { - {{- if .Errors }} - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - } - switch en.GoaErrorName() { - {{- range $gerr := .Errors }} - {{- range $err := $gerr.Errors }} - case {{ printf "%q" $err.Name }}: - {{- with $err.Response}} - return s.sendError(ctx, id, {{ .Code }}, err.Error(), err) - {{- end }} - {{- end }} - {{- end }} - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - } - {{- else }} - {{ comment "No custom errors defined - check if it's a validation error, otherwise use internal error" }} - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - {{- end }} +{{ comment "Close does nothing because the HTTP response closes when the service method returns." }} +func (s *{{ .SSE.StructDeclaration.Name }}) Close() error { + return nil } diff --git a/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl b/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl index 8b469d2394..54c060803a 100644 --- a/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl +++ b/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl @@ -1,44 +1,32 @@ -{{ comment "sseServerStream provides the SSE event encoding machinery shared by all JSON-RPC SSE server streams of the service." }} -type sseServerStream struct { - // once ensures the headers are written once. - once sync.Once - // w is the HTTP response writer used to send the SSE events. - w http.ResponseWriter - // r is the HTTP request. - r *http.Request - // encoder is the response encoder. - encoder func(context.Context, http.ResponseWriter) goahttp.Encoder -} - -{{ comment "sseEventWriter wraps http.ResponseWriter to format output as SSE events." }} -type sseEventWriter struct { - w http.ResponseWriter - eventType string - started bool -} +type ( + {{ printf "%s writes JSON-RPC messages as server-sent events." .Stream.Name | comment }} + {{ .Stream.Name }} struct { + // once writes the HTTP headers only for the first event. + once sync.Once + // w receives the HTTP headers and event bytes. + w http.ResponseWriter + // encoder turns one JSON-RPC message into bytes. + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder + } -func (s *sseEventWriter) Header() http.Header { return s.w.Header() } -func (s *sseEventWriter) WriteHeader(statusCode int) { s.w.WriteHeader(statusCode) } -func (s *sseEventWriter) Write(data []byte) (int, error) { - if !s.started { - s.started = true - if s.eventType != "" { - fmt.Fprintf(s.w, "event: %s\n", s.eventType) - } - s.w.Write([]byte("data: ")) + {{ printf "%s stores an encoded event before any HTTP output is written." .Buffer.Name | comment }} + {{ .Buffer.Name }} struct { + bytes.Buffer + header http.Header } - return s.w.Write(data) +) + +// Header returns the headers written while the event is being encoded. +func (b *{{ .Buffer.Name }}) Header() http.Header { + return b.header } -func (s *sseEventWriter) finish() { - if s.started { - s.w.Write([]byte("\n\n")) - http.NewResponseController(s.w).Flush() - } +// WriteHeader leaves the response status for the real HTTP response writer. +func (b *{{ .Buffer.Name }}) WriteHeader(int) { } -// initSSEHeaders initializes the SSE response headers -func (s *sseServerStream) initSSEHeaders() { +// initSSEHeaders writes the response headers before the first event. +func (s *{{ .Stream.Name }}) initSSEHeaders() { s.once.Do(func() { header := s.w.Header() header.Set("Content-Type", "text/event-stream") @@ -49,24 +37,35 @@ func (s *sseServerStream) initSSEHeaders() { }) } -// sendSSEEvent sends a single SSE event by creating an encoder that writes to the event writer -func (s *sseServerStream) sendSSEEvent(eventType string, v any) error { - s.initSSEHeaders() - - // Create SSE event writer that wraps the response writer - ew := &sseEventWriter{w: s.w, eventType: eventType} - - // Create encoder with the event writer and encode the value - err := s.encoder(context.Background(), ew).Encode(v) - - // Finish the SSE event (adds newlines and flushes) - ew.finish() +// sendSSEEvent encodes one event before starting the response, then writes and +// flushes that complete event. +func (s *{{ .Stream.Name }}) sendSSEEvent(ctx context.Context, eventType string, value any) error { + event := &{{ .Buffer.Name }}{header: make(http.Header)} + if err := s.encoder(ctx, event).Encode(value); err != nil { + return err + } - return err + s.initSSEHeaders() + if _, err := fmt.Fprintf(s.w, "event: %s\n", eventType); err != nil { + return fmt.Errorf("write server-sent event name: %w", err) + } + if _, err := s.w.Write([]byte("data: ")); err != nil { + return fmt.Errorf("write server-sent event data label: %w", err) + } + if _, err := s.w.Write(event.Bytes()); err != nil { + return fmt.Errorf("write server-sent event data: %w", err) + } + if _, err := s.w.Write([]byte("\n\n")); err != nil { + return fmt.Errorf("finish server-sent event: %w", err) + } + if err := http.NewResponseController(s.w).Flush(); err != nil { + return fmt.Errorf("flush server-sent event: %w", err) + } + return nil } -// sendError sends a JSON-RPC error response to the SSE stream -func (s *sseServerStream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { +// sendError writes one JSON-RPC error as a server-sent event. +func (s *{{ .Stream.Name }}) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.sendSSEEvent("error", response) + return s.sendSSEEvent(ctx, "error", response) } diff --git a/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl b/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl new file mode 100644 index 0000000000..e3e0f1c08e --- /dev/null +++ b/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl @@ -0,0 +1,10 @@ +{{ printf "%s decodes one JSON-RPC result value with the configured HTTP decoder." .Name | comment }} +func {{ .Name }}(decoder func(*http.Response) goahttp.Decoder, data json.RawMessage, target any) error { + // A JSON-RPC result is a successful HTTP value even when it arrived inside + // a server-sent event. + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(data)), + } + return decoder(resp).Decode(target) +} diff --git a/jsonrpc/codegen/templates/viewed_result_decode.go.tpl b/jsonrpc/codegen/templates/viewed_result_decode.go.tpl new file mode 100644 index 0000000000..a2dc5d2a49 --- /dev/null +++ b/jsonrpc/codegen/templates/viewed_result_decode.go.tpl @@ -0,0 +1,57 @@ +{{ printf "%s decodes the JSON body selected by the result view for the %s service %s method." .Decode.Name .ServiceName .MethodName | comment }} +func {{ .Decode.Name }}(decoder func(*http.Response) goahttp.Decoder, resp *http.Response, data json.RawMessage) ({{ .ResultRef }}, error) { + {{- if .Variable }} + var representation struct { + View *string `json:"view"` + Body *json.RawMessage `json:"body"` + } + if err := {{ .BodyDecoder.Name }}(decoder, data, &representation); err != nil { + return nil, err + } + if representation.View == nil { + return nil, goa.MissingFieldError("view", "result") + } + view := *representation.View + switch view { + {{- range .Branches }} + case {{ printf "%q" .View }}: + {{- if .ClientBody }} + if representation.Body == nil { + return nil, goa.MissingFieldError("body", "result") + } + resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) + {{- end }} + {{- template "partial_single_response" (viewedResponseData . $.ServiceName $.MethodName) }} + projected := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{ $.ViewedValue }} := {{ if not $.IsCollection }}&{{ end }}{{ $.ViewedPkg }}.{{ $.ViewedVarName }}{ + Projected: projected, + View: view, + } + if err := {{ $.ViewedPkg }}.{{ $.ViewedValidator }}({{ $.ViewedValue }}); err != nil { + return nil, err + } + return {{ $.ServicePkg }}.{{ $.ServiceResultConstructor }}({{ $.ViewedValue }}), nil + {{- end }} + default: + return nil, goa.InvalidEnumValueError("view", view, []any{ + {{- range .Branches }}{{ printf "%q" .View }},{{ end }} + }) + } + {{- else }} + {{- with index .Branches 0 }} + {{- if .ClientBody }} + resp.Body = io.NopCloser(bytes.NewBuffer(data)) + {{- end }} + {{- template "partial_single_response" (viewedResponseData . $.ServiceName $.MethodName) }} + projected := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{ $.ViewedValue }} := {{ if not $.IsCollection }}&{{ end }}{{ $.ViewedPkg }}.{{ $.ViewedVarName }}{ + Projected: projected, + View: {{ printf "%q" $.FixedView }}, + } + if err := {{ $.ViewedPkg }}.{{ $.ViewedValidator }}({{ $.ViewedValue }}); err != nil { + return nil, err + } + return {{ $.ServicePkg }}.{{ $.ServiceResultConstructor }}({{ $.ViewedValue }}), nil + {{- end }} + {{- end }} +} diff --git a/jsonrpc/codegen/templates/viewed_result_encode.go.tpl b/jsonrpc/codegen/templates/viewed_result_encode.go.tpl new file mode 100644 index 0000000000..ba24e979d2 --- /dev/null +++ b/jsonrpc/codegen/templates/viewed_result_encode.go.tpl @@ -0,0 +1,77 @@ +{{ printf "%s builds the JSON body selected by the result view for the %s service %s method." .Encode.Name .ServiceName .MethodName | comment }} +func {{ .Encode.Name }}(viewed {{ .ViewedTypeRef }}) (any, error) { + if err := {{ .ViewedPkg }}.{{ .ViewedValidator }}(viewed); err != nil { + return nil, err + } + {{- if .Variable }} + switch viewed.View { + {{- range .Branches }} + case {{ printf "%q" .View }}: + {{- if .ServerBody }} + {{- if .ServerBody.Init }} + res := viewed + body := {{ .ServerBody.Init.Declaration.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }},{{ end }}) + {{- else }} + body := viewed.Projected{{ if .ResultAttr }}.{{ .ResultAttr }}{{ end }} + {{- end }} + return struct { + View string `json:"view"` + Body any `json:"body"` + }{ + View: {{ printf "%q" .View }}, + Body: body, + }, nil + {{- else }} + return struct { + View string `json:"view"` + }{ + View: {{ printf "%q" .View }}, + }, nil + {{- end }} + {{- end }} + default: + panic("validated viewed result has no JSON-RPC representation") + } + {{- else }} + {{- with index .Branches 0 }} + {{- if .ServerBody }} + {{- if .ServerBody.Init }} + res := viewed + return {{ .ServerBody.Init.Declaration.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }},{{ end }}), nil + {{- else }} + return viewed.Projected{{ if .ResultAttr }}.{{ .ResultAttr }}{{ end }}, nil + {{- end }} + {{- else }} + return nil, nil + {{- end }} + {{- end }} + {{- end }} +} + +{{ printf "%s builds and validates the selected result view before JSON-RPC encoding." .StreamEncode.Name | comment }} +func {{ .StreamEncode.Name }}(result {{ .ResultRef }}{{ if .Variable }}, view string{{ end }}) (any, error) { + viewed := {{ .ServicePkg }}.{{ .ServiceViewedConstructor }}(result, {{ if .Variable }}view{{ else }}{{ printf "%q" .FixedView }}{{ end }}) + return {{ .Encode.Name }}(viewed) +} + +{{- if .HasResponseMetadata }} +{{ printf "%s writes the HTTP response headers and cookies selected by the validated result view." .WriteMetadata.Name | comment }} +func {{ .WriteMetadata.Name }}(w http.ResponseWriter, viewed {{ .ViewedTypeRef }}) { + {{- if .Variable }} + switch viewed.View { + {{- range .Branches }} + case {{ printf "%q" .View }}: + res := viewed + {{- template "partial_viewed_result_metadata" . }} + {{- end }} + default: + panic("validated viewed result has an unknown result view") + } + {{- else }} + {{- with index .Branches 0 }} + res := viewed + {{- template "partial_viewed_result_metadata" . }} + {{- end }} + {{- end }} +} +{{- end }} diff --git a/jsonrpc/codegen/templates/websocket_client_conn.go.tpl b/jsonrpc/codegen/templates/websocket_client_conn.go.tpl deleted file mode 100644 index c2a77f8dc5..0000000000 --- a/jsonrpc/codegen/templates/websocket_client_conn.go.tpl +++ /dev/null @@ -1,95 +0,0 @@ -{{/* -websocket_client_conn.go.tpl generates WebSocket connection management methods for JSON-RPC clients. - -This template provides connection lifecycle management including: -- Connection establishment with health checking -- Connection reuse and automatic reconnection -- Thread-safe connection access with read/write locking -- Proper cleanup on client close - -Template variables: -- .ClientStruct: Name of the generated client struct -*/}} -// getConn returns the current WebSocket connection or creates a new one -func (c *{{ .ClientStruct }}) getConn(ctx context.Context) (*websocket.Conn, error) { - c.connMu.RLock() - conn := c.conn - if conn != nil { - // Check if connection is still alive - if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err == nil { - c.connMu.RUnlock() - return conn, nil - } - // Connection is dead, need new one - } - c.connMu.RUnlock() - - // Create new connection - c.connMu.Lock() - defer c.connMu.Unlock() - - // Double-check after acquiring write lock - if c.conn != nil { - if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err == nil { - return c.conn, nil - } - // Close the dead connection - c.conn.Close() - } - - // Convert scheme for WebSocket - wsScheme := "ws" - if c.scheme == "https" { - wsScheme = "wss" - } - - // Find the WebSocket path from the service endpoints - {{- $found := false }} - {{- range .Endpoints }} - {{- range .Routes }} - {{- if and (eq .Verb "GET") (ne .Path "/") (not $found) }} - url := wsScheme + "://" + c.host + {{ printf "%q" .Path }} - {{ $found = true }} - {{- end }} - {{- end }} - {{- end }} - {{- if not $found }} - url := wsScheme + "://" + c.host - {{- end }} - - ws, _, err := c.dialer.DialContext(ctx, url, nil) - if err != nil { - return nil, goahttp.ErrRequestError("{{ .Service.Name }}", "connect", err) - } - - if c.configfn != nil { - ws = c.configfn(ws, nil) - } - - // Store the direct WebSocket connection - c.conn = ws - - return c.conn, nil -} - -// Close closes the WebSocket connection and marks the client as closed -func (c *{{ .ClientStruct }}) Close() error { - if c.closed.Swap(true) { - return nil // Already closed - } - - c.connMu.Lock() - defer c.connMu.Unlock() - - if c.conn != nil { - err := c.conn.Close() - c.conn = nil - return err - } - return nil -} - -// IsClosed returns true if the client connection has been closed -func (c *{{ .ClientStruct }}) IsClosed() bool { - return c.closed.Load() -} diff --git a/jsonrpc/codegen/templates/websocket_client_stream.go.tpl b/jsonrpc/codegen/templates/websocket_client_stream.go.tpl deleted file mode 100644 index eab73ce0b3..0000000000 --- a/jsonrpc/codegen/templates/websocket_client_stream.go.tpl +++ /dev/null @@ -1,428 +0,0 @@ -{{/* -websocket_client_stream.go.tpl generates JSON-RPC WebSocket streaming client implementations. - -This template creates stream types that handle direct WebSocket connections for JSON-RPC -streaming endpoints, providing: -- Direct WebSocket transport without intermediate wrappers -- Dual ID correlation (user payload ID + JSON-RPC request ID) -- Comprehensive error handling with user-configurable error handlers -- Generated decoder integration for consistent response parsing -- Thread-safe operations with proper lifecycle management - -Template variables: -- .VarName: Name of the generated stream struct -- .Endpoint.Method.Name: Name of the endpoint method -- .SendName/.SendTypeRef: Send method name and payload type (if stream accepts input) -- .RecvName/.RecvTypeRef: Receive method name and result type (if stream produces output) -- .Endpoint.ServiceVarName: Service name for JSON-RPC method naming - -The template handles three streaming patterns: -1. Client streaming (send-only): $hasSend && !$hasRecv -2. Server streaming (recv-only): !$hasSend && $hasRecv -3. Bidirectional streaming: $isBidirectional ($hasSend && $hasRecv) -*/}} -{{ printf "%s implements the %s client stream with direct WebSocket handling." .VarName .Endpoint.Method.Name | comment }} -{{- $hasRecv := and .RecvName .RecvTypeRef }} -{{- $hasSend := .SendName }} -{{- $isBidirectional := and $hasSend $hasRecv }} -type {{ .VarName }} struct { - // Direct WebSocket transport - ws *websocket.Conn - writeMu sync.Mutex // Serialize WebSocket writes - - // JSON-RPC correlation - pending sync.Map // map[jsonrpcID]*{{ .VarName }}PendingRequest - idGenerator atomic.Uint64 // JSON-RPC request ID generator - - // Lifecycle management - ctx context.Context - cancel context.CancelFunc - done chan struct{} // Signals stream closure - closeOnce sync.Once - - // Error handling - errorOnce sync.Once - lastError atomic.Value // Last error encountered - - // Stream configuration - config *jsonrpc.StreamConfig // Stream configuration options - {{- if $hasRecv }} - decoder func(*http.Response) goahttp.Decoder // User-provided decoder for result bodies - {{- end }} -} - - -// Stream-specific types for {{ .VarName }} -type {{ .VarName }}PendingRequest struct { - userID string // User-provided payload ID - resultChan chan {{ .VarName }}StreamResult // Buffered result delivery - timeout *time.Timer // Request timeout handling -} - -type {{ .VarName }}StreamResult struct { -{{- if $hasRecv }} - result {{ .RecvTypeRef }} -{{- end }} - err error -} - -{{- if $hasSend }} -{{ printf "%s sends streaming data to the %s endpoint with dual ID correlation." .SendName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { - return s.{{ .SendName }}WithContext(s.ctx, v) -} - -{{ printf "%sWithContext sends streaming data to the %s endpoint with context." .SendName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) {{ .SendName }}WithContext(ctx context.Context, v {{ .SendTypeRef }}) error { - // Check for stream-level errors first - if err := s.getError(); err != nil { - return err - } - -{{- if $isBidirectional }} - // Honor user-provided ID or generate one - userID := "" -{{- if .SendTypeRef }} - {{- if .Endpoint.Payload }} - // Honor user-provided ID if it exists in the payload - userID = s.generateUserID() - {{- end }} -{{- else }} - userID = s.generateUserID() -{{- end }} - - // Generate JSON-RPC protocol ID - jsonrpcID := strconv.FormatUint(s.idGenerator.Add(1), 10) - // Create pending request tracking for bidirectional streaming - pending := &{{ .VarName }}PendingRequest{ - userID: userID, - resultChan: make(chan {{ .VarName }}StreamResult, s.config.ResultChannelBuffer), - timeout: time.NewTimer(s.config.RequestTimeout), - } - - s.pending.Store(jsonrpcID, pending) - - // Construct JSON-RPC request - request := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "{{ .Endpoint.Method.Name }}", - Params: v, - ID: &jsonrpcID, - } -{{- else }} - // For payload-only streaming, use notification (fire-and-forget) - request := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "{{ .Endpoint.Method.Name }}", - Params: v, - // No ID field for notifications - } -{{- end }} - - // Send with write protection - s.writeMu.Lock() - err := s.ws.WriteJSON(request) - s.writeMu.Unlock() - - if err != nil { -{{- if $isBidirectional }} - s.pending.Delete(jsonrpcID) - pending.timeout.Stop() -{{- end }} - s.setError(err) - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, err, nil) - return fmt.Errorf("failed to send request: %w", err) - } - - return nil -} -{{- end }} - -{{- if $hasRecv }} -{{ printf "%s receives streaming data from the %s endpoint." .RecvName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { - return s.{{ .RecvName }}WithContext(s.ctx) -} - -{{ printf "%sWithContext receives streaming data from the %s endpoint with context." .RecvName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) {{ .RecvName }}WithContext(ctx context.Context) ({{ .RecvTypeRef }}, error) { - // Check for stream-level errors first - if err := s.getError(); err != nil { - return nil, err - } - -{{- if $isBidirectional }} - // Find the oldest pending request (FIFO ordering) - var oldestPending *{{ .VarName }}PendingRequest - var oldestKey string - - s.pending.Range(func(key, value any) bool { - pending := value.(*{{ .VarName }}PendingRequest) - if oldestPending == nil { - oldestPending = pending - oldestKey = key.(string) - } - return false // Take first one for FIFO - }) - - if oldestPending == nil { - return nil, fmt.Errorf("no pending requests - call {{ .SendName }}() first") - } - - // Wait for result with context cancellation - select { - case result := <-oldestPending.resultChan: - s.pending.Delete(oldestKey) - oldestPending.timeout.Stop() - return result.result, result.err - - case <-oldestPending.timeout.C: - s.pending.Delete(oldestKey) - timeoutErr := fmt.Errorf("request timeout after %v", s.config.RequestTimeout) - // Report timeout errors - s.handleError(jsonrpc.StreamErrorTimeout, timeoutErr, nil) - return nil, timeoutErr - - case <-ctx.Done(): - return nil, ctx.Err() - - case <-s.done: - if err := s.getError(); err != nil { - return nil, err - } - return nil, fmt.Errorf("stream closed") - } -{{- else }} - // For result-only streaming, make direct call - jsonrpcID := strconv.FormatUint(s.idGenerator.Add(1), 10) - - request := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "{{ .Endpoint.Method.Name }}", - Params: nil, - ID: &jsonrpcID, - } - - // Create result channel for this request - resultChan := make(chan {{ .VarName }}StreamResult, s.config.ResultChannelBuffer) - pending := &{{ .VarName }}PendingRequest{ - userID: jsonrpcID, - resultChan: resultChan, - timeout: time.NewTimer(s.config.RequestTimeout), - } - - s.pending.Store(jsonrpcID, pending) - defer func() { - s.pending.Delete(jsonrpcID) - pending.timeout.Stop() - }() - - // Send request - s.writeMu.Lock() - err := s.ws.WriteJSON(request) - s.writeMu.Unlock() - - if err != nil { - s.setError(err) - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, err, nil) - return nil, fmt.Errorf("failed to send request: %w", err) - } - - // Wait for response - select { - case result := <-resultChan: - return result.result, result.err - case <-pending.timeout.C: - timeoutErr := fmt.Errorf("request timeout after %v", s.config.RequestTimeout) - // Report timeout errors - s.handleError(jsonrpc.StreamErrorTimeout, timeoutErr, nil) - return nil, timeoutErr - case <-ctx.Done(): - return nil, ctx.Err() - case <-s.done: - if err := s.getError(); err != nil { - return nil, err - } - return nil, fmt.Errorf("stream closed") - } -{{- end }} -} -{{- end }} - -// responseHandler processes incoming WebSocket messages in a background goroutine -func (s *{{ .VarName }}) responseHandler() { - defer close(s.done) - - for { - select { - case <-s.ctx.Done(): - s.cleanupPendingRequests(s.ctx.Err()) - return - default: - var response jsonrpc.RawResponse - if err := s.ws.ReadJSON(&response); err != nil { - connectionErr := fmt.Errorf("failed to read response: %w", err) - s.setError(connectionErr) - - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, connectionErr, nil) - - s.cleanupPendingRequests(connectionErr) - return - } - - s.handleResponse(&response) - } - } -} - -func (s *{{ .VarName }}) handleResponse(response *jsonrpc.RawResponse) { - if response.ID == nil { - // This is a server-initiated notification - // For now, just report it as an event via the error handler - // In the future, we could add a dedicated notification handler - if s.config.ErrorHandler != nil { - s.config.ErrorHandler(s.ctx, jsonrpc.StreamErrorNotification, - fmt.Errorf("received server notification"), response) - } - return - } - - jsonrpcID := response.ID - pendingInterface, exists := s.pending.LoadAndDelete(jsonrpcID) - if !exists { - // Orphaned response - report to error handler - s.handleError(jsonrpc.StreamErrorOrphaned, fmt.Errorf("received response for unknown ID: %s", jsonrpcID), response) - return - } - - pending := pendingInterface.(*{{ .VarName }}PendingRequest) - pending.timeout.Stop() - - var result {{ .VarName }}StreamResult - - if response.Error != nil { - result.err = response.Error - // Report protocol-level JSON-RPC errors - s.handleError(jsonrpc.StreamErrorProtocol, response.Error, response) - } else { -{{- if $hasRecv }} - // Use generated decoder for consistent response parsing - parsedResult, err := s.decodeResponse(response.Result) - if err != nil { - result.err = fmt.Errorf("failed to decode response: %w", err) - // Report parsing errors - s.handleError(jsonrpc.StreamErrorParsing, err, response) - } else { - {{- if .Endpoint.Result.IDAttribute }} - // Backfill the result ID from the envelope when missing - {{- if .Endpoint.Result.IDAttributeRequired }} - if parsedResult.{{ .Endpoint.Result.IDAttribute }} == "" { - parsedResult.{{ .Endpoint.Result.IDAttribute }} = jsonrpc.IDToString(response.ID) - } - {{- else }} - if parsedResult.{{ .Endpoint.Result.IDAttribute }} == nil || *parsedResult.{{ .Endpoint.Result.IDAttribute }} == "" { - idCopy := jsonrpc.IDToString(response.ID) - parsedResult.{{ .Endpoint.Result.IDAttribute }} = &idCopy - } - {{- end }} - {{- end }} - result.result = parsedResult - } -{{- end }} - } - - // Non-blocking send to result channel - select { - case pending.resultChan <- result: - default: - // Channel full - should not happen with buffer size 1 - } -} - -// Helper methods -func (s *{{ .VarName }}) generateUserID() string { - return fmt.Sprintf("user-%d-%d", time.Now().UnixNano(), s.idGenerator.Load()) -} - -// handleError calls the user-provided error handler if available -func (s *{{ .VarName }}) handleError(errorType jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { - if s.config.ErrorHandler != nil { - s.config.ErrorHandler(s.ctx, errorType, err, response) - } -} - - -{{- if $hasRecv }} -// decodeResponse decodes JSON-RPC response data using the user-provided decoder -func (s *{{ .VarName }}) decodeResponse(data json.RawMessage) ({{ .RecvTypeRef }}, error) { - // Create minimal HTTP response with raw JSON data for user's decoder - resp := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader(data)), - } - - // Use user-provided decoder to decode the result (expects inner result JSON) - dec := s.decoder(resp) - var out {{ .RecvTypeRef }} - if err := dec.Decode(&out); err != nil { - return nil, err - } - return out, nil -} -{{- end }} - -func (s *{{ .VarName }}) setError(err error) { - s.errorOnce.Do(func() { - s.lastError.Store(err) - s.cancel() // Cancel context to signal error state - }) -} - -func (s *{{ .VarName }}) getError() error { - if err, ok := s.lastError.Load().(error); ok { - return err - } - return nil -} - -func (s *{{ .VarName }}) cleanupPendingRequests(err error) { - s.pending.Range(func(key, value any) bool { - pending := value.(*{{ .VarName }}PendingRequest) - pending.timeout.Stop() - - select { - case pending.resultChan <- {{ .VarName }}StreamResult{err: err}: - default: - } - - s.pending.Delete(key) - return true - }) -} - -{{ printf "Close closes the stream and cleans up resources." | comment }} -func (s *{{ .VarName }}) Close() error { - var err error - s.closeOnce.Do(func() { - s.cancel() - - // Wait for response handler to finish - select { - case <-s.done: - case <-time.After(s.config.CloseTimeout): - // Force close if handler doesn't respond - } - - // Clean up any remaining pending requests - s.cleanupPendingRequests(fmt.Errorf("stream closed")) - - // Close the WebSocket connection - if s.ws != nil { - err = s.ws.Close() - } - }) - return err -} diff --git a/jsonrpc/codegen/templates/websocket_server_close.go.tpl b/jsonrpc/codegen/templates/websocket_server_close.go.tpl deleted file mode 100644 index 84741806db..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_close.go.tpl +++ /dev/null @@ -1,15 +0,0 @@ -{{ printf "Close closes the %s service websocket connection." .Service.Name | comment }} -func (s *{{ lowerInitial .Service.StructName }}Stream) Close() error { - var err error - if s.conn == nil { - return nil - } - if err = s.conn.WriteControl( - websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "server closing connection"), - time.Now().Add(time.Second), - ); err != nil { - return err - } - return s.conn.Close() -} diff --git a/jsonrpc/codegen/templates/websocket_server_handler.go.tpl b/jsonrpc/codegen/templates/websocket_server_handler.go.tpl deleted file mode 100644 index cc5ad4c51a..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_handler.go.tpl +++ /dev/null @@ -1,28 +0,0 @@ -// ServeHTTP handles WebSocket JSON-RPC requests. -func (s *{{ .ServerStruct }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithCancel(r.Context()) - conn, err := s.upgrader.Upgrade(w, r, nil) - if err != nil { - s.errhandler(r.Context(), w, fmt.Errorf("failed to upgrade to WebSocket: %w", err)) - cancel() - return - } - if s.configfn != nil { - conn = s.configfn(conn, cancel) - } - defer conn.Close() - - stream := &{{ lowerInitial .Service.StructName }}Stream{ - {{- range .Endpoints }} - {{ lowerInitial .Method.VarName }}: s.{{ lowerInitial .Method.VarName }}, - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - {{ lowerInitial .Method.VarName }}Endpoint: s.{{ lowerInitial .Method.VarName }}Endpoint, - {{- end }} - {{- end }} - r: r, - w: w, - conn: conn, - cancel: cancel, - } - s.StreamHandler(ctx, stream) -} diff --git a/jsonrpc/codegen/templates/websocket_server_recv.go.tpl b/jsonrpc/codegen/templates/websocket_server_recv.go.tpl deleted file mode 100644 index c4b27eeaf2..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_recv.go.tpl +++ /dev/null @@ -1,110 +0,0 @@ -{{ printf "Recv reads JSON-RPC requests from the %s service stream." .Service.Name | comment }} -func (s *{{ lowerInitial .Service.StructName }}Stream) Recv(ctx context.Context) error { - var req jsonrpc.RawRequest - if err := s.conn.ReadJSON(&req); err != nil { - // Handle different types of errors gracefully - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - // Network/connection errors - terminate connection - return err - } - - // JSON parse errors - send Parse Error response and continue - if err := s.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { - // If we can't send error response, connection is broken - return fmt.Errorf("failed to send parse error: %w", err) - } - // Continue processing after sending parse error - return nil - } - return s.processRequest(ctx, &req) -} - -func (s *{{ lowerInitial .Service.StructName }}Stream) processRequest(ctx context.Context, req *jsonrpc.RawRequest) error { - if req.JSONRPC != "2.0" { - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) - } - return nil - } - - if req.Method == "" { - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) - } - return nil - } - - switch req.Method { - {{- range .Endpoints }} - case {{ printf "%q" .Method.Name }}: - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - // {{ if eq .Method.ServerStream.Kind 3 }}Server{{ else }}Bidirectional{{ end }} streaming: decode payload and create stream wrapper - {{- if .Payload.Ref }} - payload, err := s.{{ lowerInitial .Method.VarName }}(ctx, s.r, req) - {{- else }} - _, err := s.{{ lowerInitial .Method.VarName }}(ctx, s.r, req) - {{- end }} - if err != nil { - return fmt.Errorf("handler error for %s: %w", {{ printf "%q" .Method.Name }}, err) - } - // Create wrapper that implements the method-specific stream interface - streamWrapper := &{{ lowerInitial .Method.VarName }}StreamWrapper{ - stream: s, - requestID: req.ID, - } - // Call the endpoint with payload and stream wrapper - endpointInput := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - {{- if .Payload.Ref }} - Payload: payload.({{ .Payload.Ref }}), - {{- end }} - Stream: streamWrapper, - } - if _, err := s.{{ lowerInitial .Method.VarName }}Endpoint(ctx, endpointInput); err != nil { - // For streaming endpoints, send error as JSON-RPC error response - if req.HasID { - // Send error response to client - if sendErr := streamWrapper.SendError(ctx, err); sendErr != nil { - return fmt.Errorf("failed to send error response: %w", sendErr) - } - // Continue processing other requests - return nil - } - // For notifications (no ID), just log and continue - return nil - } - return nil - {{- else }} - res, err := s.{{ lowerInitial .Method.VarName }}(ctx, s.r, req) - if err != nil { - // For non-streaming, send JSON-RPC error if request has an ID; otherwise continue - if req.HasID { - if sendErr := s.SendError(ctx, req.ID, err); sendErr != nil { - return fmt.Errorf("failed to send error response: %w", sendErr) - } - } - return nil - } - // Only send a response if the request has an ID (i.e., it's not a notification) - if req.HasID { - if res == nil { - return s.sendError(ctx, req.ID, jsonrpc.InternalError, "Internal error", nil) - } - if r, ok := res.({{ printf "*%s.%sResult" .ServicePkgName .Method.VarName }}); ok { - if err := s.Send{{ .Method.VarName }}Response(ctx, req.ID, r); err != nil { - return fmt.Errorf("send response error for %s: %w", {{ printf "%q" .Method.Name }}, err) - } - } else { - return s.sendError(ctx, req.ID, jsonrpc.InternalError, "Internal error", nil) - } - } - return nil - {{- end }} - {{- end }} - default: - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil) - } - return nil - } -} - diff --git a/jsonrpc/codegen/templates/websocket_server_send.go.tpl b/jsonrpc/codegen/templates/websocket_server_send.go.tpl deleted file mode 100644 index 80185bfaa5..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_send.go.tpl +++ /dev/null @@ -1,75 +0,0 @@ -{{- range .Endpoints }} - {{- if .Result.Ref }} -{{ printf "Send%sNotification sends a JSON-RPC notification for the %s method." .Method.VarName .Method.Name | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) Send{{ .Method.VarName }}Notification(ctx context.Context, result {{ .Result.Ref }}) error { - {{- if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) - {{- else }} - body := result - {{- end }} - return s.conn.WriteJSON(jsonrpc.MakeNotification({{ printf "%q" .Method.Name }}, body)) -} - -{{ printf "Send%sResponse sends a JSON-RPC response for the %s method." .Method.VarName .Method.Name | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) Send{{ .Method.VarName }}Response(ctx context.Context, id any, result {{ .Result.Ref }}) error { - {{- if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) - {{- else }} - body := result - {{- end }} - return s.conn.WriteJSON(jsonrpc.MakeSuccessResponse(id, body)) -} - {{- end }} -{{- end }} - - -{{ printf "SendError streams JSON-RPC errors." | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) SendError(ctx context.Context, id any, err error) error { - {{- if allErrors . }} - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - } - switch en.GoaErrorName() { - {{- range allErrors . }} - case {{ printf "%q" .Name }}: - {{- with .Response}} - return s.sendError(ctx, id, {{ .Code }}, err.Error(), err) - {{- end }} - {{- end }} - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - } - {{- else }} - // No custom errors defined - check if it's a validation error, otherwise use internal error - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - {{- end }} -} - -{{ printf "send writes a JSON-RPC response to the websocket connection." | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) send(id any, method string, result any) error { - // If there's no ID, send as a notification instead of a response - // A JSON-RPC result with no ID is invalid per the spec - if id == nil || id == "" { - return s.conn.WriteJSON(jsonrpc.MakeNotification(method, result)) - } - return s.conn.WriteJSON(jsonrpc.MakeSuccessResponse(id, result)) -} - -{{ printf "sendError sends a JSON-RPC error response to the websocket connection." | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { - response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.conn.WriteJSON(response) -} diff --git a/jsonrpc/codegen/templates/websocket_server_stream.go.tpl b/jsonrpc/codegen/templates/websocket_server_stream.go.tpl deleted file mode 100644 index cc1470b656..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_stream.go.tpl +++ /dev/null @@ -1,19 +0,0 @@ -{{ printf "%sStream implements the Stream interface." (lowerInitial .Service.StructName) | comment }} -type {{ lowerInitial .Service.StructName }}Stream struct { -{{- range .Endpoints }} - {{ printf "%s decodes requests for the %s method" (lowerInitial .Method.VarName) .Method.Name | comment }} - {{ lowerInitial .Method.VarName }} func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - {{ printf "%sEndpoint is the endpoint for the %s method" (lowerInitial .Method.VarName) .Method.Name | comment }} - {{ lowerInitial .Method.VarName }}Endpoint goa.Endpoint - {{- end }} -{{- end }} - {{ comment "cancel is the context cancellation function which cancels the request context when invoked." }} - cancel context.CancelFunc - {{ comment "w is the HTTP response writer used in upgrading the connection." }} - w http.ResponseWriter - {{ comment "r is the HTTP request." }} - r *http.Request - {{ comment "conn is the underlying websocket connection." }} - conn *websocket.Conn -} diff --git a/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl b/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl deleted file mode 100644 index 0492fc1415..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl +++ /dev/null @@ -1,29 +0,0 @@ -{{- range .Endpoints }} - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} -// {{ lowerInitial .Method.VarName }}StreamWrapper wraps the JSON-RPC stream to provide a method-specific interface. -type {{ lowerInitial .Method.VarName }}StreamWrapper struct { - stream *{{ lowerInitial $.Service.StructName }}Stream - requestID any // Store the JSON-RPC request ID for responses -} - -// SendNotification sends a notification to the client (no response expected). -func (w *{{ lowerInitial .Method.VarName }}StreamWrapper) SendNotification(ctx context.Context, res {{ .Result.Ref }}) error { - return w.stream.Send{{ .Method.VarName }}Notification(ctx, res) -} - -// SendResponse sends a response to the client for the original request. -func (w *{{ lowerInitial .Method.VarName }}StreamWrapper) SendResponse(ctx context.Context, res {{ .Result.Ref }}) error { - return w.stream.Send{{ .Method.VarName }}Response(ctx, w.requestID, res) -} - -// SendError sends an error response to the client. -func (w *{{ lowerInitial .Method.VarName }}StreamWrapper) SendError(ctx context.Context, err error) error { - return w.stream.SendError(ctx, w.requestID, err) -} - -// Close closes the underlying JSON-RPC stream. -func (w *{{ lowerInitial .Method.VarName }}StreamWrapper) Close() error { - return w.stream.Close() -} - {{- end }} -{{- end }} \ No newline at end of file diff --git a/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl b/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl deleted file mode 100644 index b5f91fa648..0000000000 --- a/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl +++ /dev/null @@ -1,13 +0,0 @@ -// Stream error types for comprehensive error reporting -type StreamErrorType int - -const ( - StreamErrorConnection StreamErrorType = iota // WebSocket connection errors - StreamErrorProtocol // Invalid JSON-RPC protocol - StreamErrorParsing // Failed to parse/decode response - StreamErrorOrphaned // Response with no matching request - StreamErrorTimeout // Request timeout -) - -// StreamErrorHandler allows users to handle stream errors -type StreamErrorHandler func(ctx context.Context, errorType StreamErrorType, err error, response *jsonrpc.RawResponse) diff --git a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden index a7face3f7f..5a67c060bd 100644 --- a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden +++ b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden @@ -1,94 +1,30 @@ // StreamServerStream implements the jsonrpcsseobjectservice.StreamServerStream // interface using Server-Sent Events. type StreamServerStream struct { - // sseServerStream provides the shared SSE event encoding machinery + // sseServerStream writes JSON-RPC messages as server-sent events. sseServerStream - // requestID is the JSON-RPC request ID for sending final response - requestID any - // closed indicates if the stream has been closed via SendAndClose - closed bool - // mu protects the closed flag - mu sync.Mutex } -// Send sends a JSON-RPC notification to the client. -// Notifications do not expect a response from the client. -func (s *StreamServerStream) Send(ctx context.Context, event jsonrpcsseobjectservice.StreamEvent) error { - // Check if stream is closed - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream closed") - } - s.mu.Unlock() +// Send streams instances of "StreamResult". +func (s *StreamServerStream) Send(event *jsonrpcsseobjectservice.StreamResult) error { + return s.SendWithContext(context.Background(), event) +} - // Type assert to the specific result type - result, ok := event.(*jsonrpcsseobjectservice.StreamResult) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - // Convert to response body type for proper JSON encoding +// SendWithContext streams instances of "StreamResult" with context. +func (s *StreamServerStream) SendWithContext(ctx context.Context, event *jsonrpcsseobjectservice.StreamResult) error { + result := event body := NewStreamResponseBody(result) - // Send as notification (no ID) message := map[string]any{ "jsonrpc": "2.0", "method": "Stream", "params": body, } - - return s.sendSSEEvent("notification", message) -} - -// SendAndClose sends a final JSON-RPC response to the client and closes the -// stream. -// The response will include the original request ID unless the result has an -// ID field populated. -// After calling this method, no more events can be sent on this stream. -func (s *StreamServerStream) SendAndClose(ctx context.Context, event jsonrpcsseobjectservice.StreamEvent) error { - // Check if stream is already closed - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream already closed") - } - s.closed = true - s.mu.Unlock() - - // Type assert to the specific result type - result, ok := event.(*jsonrpcsseobjectservice.StreamResult) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - - // Determine the ID to use for the response - var id any = s.requestID - if result.ID != nil && *result.ID != "" { - // Use the ID from the result if provided - id = *result.ID - // Clear the ID field so it's not duplicated in the result - result.ID = nil - } - // Convert to response body type for proper JSON encoding - body := NewStreamResponseBody(result) - - // Send as response with ID - message := map[string]any{ - "jsonrpc": "2.0", - "id": id, - "result": body, - } - - return s.sendSSEEvent("response", message) + return s.sendSSEEvent(ctx, "notification", message) } -// SendError sends a JSON-RPC error response. -func (s *StreamServerStream) SendError(ctx context.Context, id string, err error) error { - // No custom errors defined - check if it's a validation error, otherwise use - // internal error - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *StreamServerStream) Close() error { + return nil } diff --git a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden index b788f135fe..11186529fa 100644 --- a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden +++ b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden @@ -1,86 +1,30 @@ // StreamServerStream implements the jsonrpcssestringservice.StreamServerStream // interface using Server-Sent Events. type StreamServerStream struct { - // sseServerStream provides the shared SSE event encoding machinery + // sseServerStream writes JSON-RPC messages as server-sent events. sseServerStream - // requestID is the JSON-RPC request ID for sending final response - requestID any - // closed indicates if the stream has been closed via SendAndClose - closed bool - // mu protects the closed flag - mu sync.Mutex } -// Send sends a JSON-RPC notification to the client. -// Notifications do not expect a response from the client. -func (s *StreamServerStream) Send(ctx context.Context, event jsonrpcssestringservice.StreamEvent) error { - // Check if stream is closed - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream closed") - } - s.mu.Unlock() +// Send streams instances of "string". +func (s *StreamServerStream) Send(event string) error { + return s.SendWithContext(context.Background(), event) +} - // Type assert to the specific result type - result, ok := event.(string) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } +// SendWithContext streams instances of "string" with context. +func (s *StreamServerStream) SendWithContext(ctx context.Context, event string) error { + result := event body := result - // Send as notification (no ID) message := map[string]any{ "jsonrpc": "2.0", "method": "Stream", "params": body, } - - return s.sendSSEEvent("notification", message) + return s.sendSSEEvent(ctx, "notification", message) } -// SendAndClose sends a final JSON-RPC response to the client and closes the -// stream. -// The response will include the original request ID unless the result has an -// ID field populated. -// After calling this method, no more events can be sent on this stream. -func (s *StreamServerStream) SendAndClose(ctx context.Context, event jsonrpcssestringservice.StreamEvent) error { - // Check if stream is already closed - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream already closed") - } - s.closed = true - s.mu.Unlock() - - // Type assert to the specific result type - result, ok := event.(string) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - - // Determine the ID to use for the response - var id any = s.requestID - body := result - - // Send as response with ID - message := map[string]any{ - "jsonrpc": "2.0", - "id": id, - "result": body, - } - - return s.sendSSEEvent("response", message) -} - -// SendError sends a JSON-RPC error response. -func (s *StreamServerStream) SendError(ctx context.Context, id string, err error) error { - // No custom errors defined - check if it's a validation error, otherwise use - // internal error - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *StreamServerStream) Close() error { + return nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/calc.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/calc.go.golden index eb2d4af3e6..0f531f073c 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/calc.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/calc.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - calc "kitchensink/calc" + calc "generated.local/gen/calc" "goa.design/clue/log" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden deleted file mode 100644 index f569ea3367..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden +++ /dev/null @@ -1,52 +0,0 @@ -package kitchensink - -import ( - "context" - chat "kitchensink/chat" - - "goa.design/clue/log" -) - -// Chat service example implementation. -// The example methods log the requests and return zero values. -type chatsrvc struct{} - -// NewChat returns the Chat service implementation. -func NewChat() chat.Service { - return &chatsrvc{} -} - -// Echo implements echo. -func (s *chatsrvc) Echo(ctx context.Context, p *chat.EchoPayload, stream chat.EchoServerStream) (err error) { - log.Printf(ctx, "chat.echo") - // Minimal example: emit one progress notification and one final response - { - // Progress notification (no ID) - notif := &chat.EchoResult{} - if err := stream.Send(ctx, notif); err != nil { - return err - } - // Final response - final := &chat.EchoResult{} - return stream.SendAndClose(ctx, final) - } - return -} - -// HandleStream manages a JSON-RPC WebSocket connection, enabling bidirectional -// communication between the server and client. It receives requests from the -// client, dispatches them to the appropriate service methods, and can send -// server-initiated messages back to the client as needed. -func (s *chatsrvc) HandleStream(ctx context.Context, stream chat.Stream) error { - log.Printf(ctx, "chat.HandleStream") - - // Example: In a real implementation you might read from an event source - // and send notifications via stream.Send(ctx, event). This stub returns - // when the context is canceled. - select { - case <-ctx.Done(): - return ctx.Err() - default: - return nil - } -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden index e87b0383d2..f0b99b05eb 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden @@ -1,16 +1,18 @@ package main import ( + "context" + "flag" "fmt" - cli "kitchensink/http/cli/kitchen_sink" + "io" "net/http" "time" + cli "generated.local/gen/http/cli/kitchen_sink" goahttp "goa.design/goa/v3/http" - goa "goa.design/goa/v3/pkg" ) -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -30,13 +32,22 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er debug, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "mixed": + switch flag.Arg(1) { + case "lookup": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "health": + switch flag.Arg(1) { + case "check": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden index 00e4e3bdde..ce81913622 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden @@ -1,17 +1,19 @@ package main import ( + "context" + "flag" "fmt" - cli "kitchensink/jsonrpc/cli/kitchen_sink" + "io" "net/http" "time" - "github.com/gorilla/websocket" + feed "generated.local/gen/feed" + cli2 "generated.local/gen/jsonrpc/cli/kitchen_sink" goahttp "goa.design/goa/v3/http" - goa "goa.design/goa/v3/pkg" ) -func doJSONRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doJSONRPC(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -22,33 +24,49 @@ func doJSONRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, } } - var ( - dialer *websocket.Dialer - ) - { - dialer = websocket.DefaultDialer - } - - endpoint, payload, err := cli.ParseEndpoint( + endpoint, payload, err := cli2.ParseEndpoint( scheme, host, doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, debug, - dialer, - nil, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func jsonrpcUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "calc": + switch flag.Arg(1) { + case "add": + return writeEndpointResult(ctx, stdout, endpoint, payload) + case "ping": + return writeEndpointResult(ctx, stdout, endpoint, payload) + case "log": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "feed": + switch flag.Arg(1) { + case "watch": + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.(feed.WatchClientStream) + return writeStreamResults(ctx, stdout, stream.RecvWithContext) + case "snapshot": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "mixed": + switch flag.Arg(1) { + case "lookup": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed JSON-RPC command has no generated result writer") } func jsonrpcUsageExamples() string { - return cli.UsageExamples() + return cli2.UsageExamples() } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/main.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/main.go.golden index c450c8697b..2db5370daa 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/main.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/main.go.golden @@ -6,10 +6,9 @@ import ( "errors" "flag" "fmt" + "io" "net/url" "os" - "slices" - "sort" "strings" goa "goa.design/goa/v3/pkg" @@ -64,19 +63,37 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": if *jsonrpcF || *jF { - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) } else { - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) - if err != nil && strings.HasPrefix(err.Error(), "unknown") { - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + switch flag.Arg(0) { + case "calc": + switch flag.Arg(1) { + case "add": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + case "ping": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + case "log": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + } + case "feed": + switch flag.Arg(1) { + case "watch": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + case "snapshot": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + } + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) } } default: @@ -93,24 +110,55 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err + } + return writeJSON(stdout, data) +} + +// writeStreamResults writes each server result until the server ends the stream. +func writeStreamResults[T any](ctx context.Context, stdout io.Writer, recv func(context.Context) (T, error)) error { + for { + data, err := recv(ctx) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("receive result: %w", err) + } + if err := writeJSON(stdout, data); err != nil { + return err + } } +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - usageCommands = append(usageCommands, jsonrpcUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "calc (add|ping|log)", + "feed (watch|snapshot)", + "health check", + "mixed lookup", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the kitchen-sink API. Usage: diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden index da5f5da0fc..7dc70c327f 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden @@ -2,23 +2,20 @@ package main import ( "context" - calc "kitchensink/calc" - chat "kitchensink/chat" - feed "kitchensink/feed" - health "kitchensink/health" - healthsvr "kitchensink/http/health/server" - mixedsvr "kitchensink/http/mixed/server" - calcjssvr "kitchensink/jsonrpc/calc/server" - chatjssvr "kitchensink/jsonrpc/chat/server" - feedjssvr "kitchensink/jsonrpc/feed/server" - mixedjssvr "kitchensink/jsonrpc/mixed/server" - mixed "kitchensink/mixed" "net/http" "net/url" "sync" "time" - "github.com/gorilla/websocket" + calc "generated.local/gen/calc" + feed "generated.local/gen/feed" + health "generated.local/gen/health" + healthsvr "generated.local/gen/http/health/server" + mixedsvr "generated.local/gen/http/mixed/server" + calcjssvr "generated.local/gen/jsonrpc/calc/server" + feedjssvr "generated.local/gen/jsonrpc/feed/server" + mixedjssvr "generated.local/gen/jsonrpc/mixed/server" + mixed "generated.local/gen/mixed" "goa.design/clue/debug" "goa.design/clue/log" goahttp "goa.design/goa/v3/http" @@ -26,7 +23,7 @@ import ( // handleHTTPServer starts configures and starts a HTTP server on the given // URL. It shuts down the server if any error is received in the error channel. -func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.Endpoints, healthEndpoints *health.Endpoints, calcSvc calc.Service, calcEndpoints *calc.Endpoints, chatSvc chat.Service, chatEndpoints *chat.Endpoints, feedSvc feed.Service, feedEndpoints *feed.Endpoints, mixedSvc mixed.Service, wg *sync.WaitGroup, errc chan error, dbg bool) { +func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.Endpoints, healthEndpoints *health.Endpoints, calcSvc calc.Service, calcEndpoints *calc.Endpoints, feedSvc feed.Service, feedEndpoints *feed.Endpoints, mixedSvc mixed.Service, wg *sync.WaitGroup, errc chan error, dbg bool) { // Provide the transport specific request decoder and response encoder. // The goa http package has built-in support for JSON, XML and gob. // Other encodings can be used by providing the corresponding functions, @@ -57,17 +54,14 @@ func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.End mixedServer *mixedsvr.Server healthServer *healthsvr.Server calcJSONRPCServer *calcjssvr.Server - chatJSONRPCServer *chatjssvr.Server feedJSONRPCServer *feedjssvr.Server mixedJSONRPCServer *mixedjssvr.Server ) { eh := errorHandler(ctx) - upgrader := &websocket.Upgrader{} mixedServer = mixedsvr.New(mixedEndpoints, mux, dec, enc, eh, nil) healthServer = healthsvr.New(healthEndpoints, mux, dec, enc, eh, nil) calcJSONRPCServer = calcjssvr.New(calcEndpoints, mux, dec, enc, eh) - chatJSONRPCServer = chatjssvr.New(chatSvc.HandleStream, chatEndpoints, mux, dec, enc, eh, upgrader, nil) feedJSONRPCServer = feedjssvr.New(feedEndpoints, mux, dec, enc, eh) mixedJSONRPCServer = mixedjssvr.New(mixedEndpoints, mux, dec, enc, eh) } @@ -76,7 +70,6 @@ func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.End mixedsvr.Mount(mux, mixedServer) healthsvr.Mount(mux, healthServer) calcjssvr.Mount(mux, calcJSONRPCServer) - chatjssvr.Mount(mux, chatJSONRPCServer) feedjssvr.Mount(mux, feedJSONRPCServer) mixedjssvr.Mount(mux, mixedJSONRPCServer) @@ -99,9 +92,6 @@ func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.End for _, m := range calcJSONRPCServer.Methods { log.Printf(ctx, "JSON-RPC method %q mounted on POST /rpc", m) } - for _, m := range chatJSONRPCServer.Methods { - log.Printf(ctx, "JSON-RPC method %q mounted on GET /ws/ws", m) - } for _, m := range feedJSONRPCServer.Methods { log.Printf(ctx, "JSON-RPC method %q mounted on POST /feed", m) } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden index 01d6a7cb4a..49d67debe1 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden @@ -4,11 +4,6 @@ import ( "context" "flag" "fmt" - calc "kitchensink/calc" - chat "kitchensink/chat" - feed "kitchensink/feed" - health "kitchensink/health" - mixed "kitchensink/mixed" "net" "net/url" "os" @@ -16,7 +11,11 @@ import ( "sync" "syscall" - kitchensink "." + kitchensink "generated.local" + calc "generated.local/gen/calc" + feed "generated.local/gen/feed" + health "generated.local/gen/health" + mixed "generated.local/gen/mixed" "goa.design/clue/debug" "goa.design/clue/log" ) @@ -48,14 +47,12 @@ func main() { // Initialize the services. var ( calcSvc calc.Service - chatSvc chat.Service feedSvc feed.Service mixedSvc mixed.Service healthSvc health.Service ) { calcSvc = kitchensink.NewCalc() - chatSvc = kitchensink.NewChat() feedSvc = kitchensink.NewFeed() mixedSvc = kitchensink.NewMixed() healthSvc = kitchensink.NewHealth() @@ -65,7 +62,6 @@ func main() { // potentially running in different processes. var ( calcEndpoints *calc.Endpoints - chatEndpoints *chat.Endpoints feedEndpoints *feed.Endpoints mixedEndpoints *mixed.Endpoints healthEndpoints *health.Endpoints @@ -74,9 +70,6 @@ func main() { calcEndpoints = calc.NewEndpoints(calcSvc) calcEndpoints.Use(debug.LogPayloads()) calcEndpoints.Use(log.Endpoint) - chatEndpoints = chat.NewEndpoints(chatSvc) - chatEndpoints.Use(debug.LogPayloads()) - chatEndpoints.Use(log.Endpoint) feedEndpoints = feed.NewEndpoints(feedSvc) feedEndpoints.Use(debug.LogPayloads()) feedEndpoints.Use(log.Endpoint) @@ -127,7 +120,7 @@ func main() { } else if u.Port() == "" { u.Host = net.JoinHostPort(u.Host, "80") } - handleHTTPServer(ctx, u, mixedEndpoints, healthEndpoints, calcSvc, calcEndpoints, chatSvc, chatEndpoints, feedSvc, feedEndpoints, mixedSvc, &wg, errc, *dbgF) + handleHTTPServer(ctx, u, mixedEndpoints, healthEndpoints, calcSvc, calcEndpoints, feedSvc, feedEndpoints, mixedSvc, &wg, errc, *dbgF) } default: diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden index 8fdbe4aca2..6b7f16c5b9 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - feed "kitchensink/feed" + feed "generated.local/gen/feed" "goa.design/clue/log" ) @@ -19,16 +19,11 @@ func NewFeed() feed.Service { // Watch implements watch. func (s *feedsrvc) Watch(ctx context.Context, p *feed.WatchPayload, stream feed.WatchServerStream) (err error) { log.Printf(ctx, "feed.watch") - // Minimal example: emit one progress notification and one final response - { - // Progress notification (no ID) - notif := &feed.WatchResult{} - if err := stream.Send(ctx, notif); err != nil { - return err - } - // Final response - final := &feed.WatchResult{} - return stream.SendAndClose(ctx, final) - } + return +} + +// Snapshot implements snapshot. +func (s *feedsrvc) Snapshot(ctx context.Context, p *feed.SnapshotPayload) (res string, err error) { + log.Printf(ctx, "feed.snapshot") return } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden index 960a032aba..1562d2643d 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden @@ -10,11 +10,11 @@ package cli import ( "flag" "fmt" - healthc "kitchensink/http/health/client" - mixedc "kitchensink/http/mixed/client" "net/http" "os" + healthc "generated.local/gen/http/health/client" + mixedc "generated.local/gen/http/mixed/client" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) @@ -31,7 +31,7 @@ func UsageCommands() []string { // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { - return os.Args[0] + " " + "mixed lookup --body '{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'" + "\n" + + return os.Args[0] + " " + "mixed lookup --body '{\n \"id\": \"Cupiditate cupiditate minus veniam sed officia qui.\",\n \"key\": \"Numquam iusto molestias nulla quod nobis molestias.\"\n }'" + "\n" + os.Args[0] + " " + "health check" + "\n" + "" } @@ -176,7 +176,7 @@ func mixedLookupUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "mixed lookup --body '{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "mixed lookup --body '{\n \"id\": \"Cupiditate cupiditate minus veniam sed officia qui.\",\n \"key\": \"Numquam iusto molestias nulla quod nobis molestias.\"\n }'") } // healthUsage displays the usage of the health command and its subcommands. diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/client/encode_decode.go.golden index 1ad12c6838..a4f1eebf6a 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/client/encode_decode.go.golden @@ -10,6 +10,7 @@ package client import ( "bytes" "context" + "errors" "io" "net/http" "net/url" @@ -36,18 +37,24 @@ func (c *Client) BuildCheckRequest(ctx context.Context, v any) (*http.Request, e // check endpoint. restoreBody controls whether the response body should be // restored after having been read. func DecodeCheckResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Health", "check", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Health", "check", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -61,7 +68,10 @@ func DecodeCheckResponse(decoder func(*http.Response) goahttp.Decoder, restoreBo } return body, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Health", "check", err) + } return nil, goahttp.ErrInvalidResponse("Health", "check", resp.StatusCode, string(body)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/server/server.go.golden index 21812454bd..75797ae4a0 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/server/server.go.golden @@ -9,9 +9,9 @@ package server import ( "context" - health "kitchensink/health" "net/http" + health "generated.local/gen/health" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden index 9c72b09db6..96a7b9543f 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden @@ -10,7 +10,8 @@ package client import ( "encoding/json" "fmt" - mixed "kitchensink/mixed" + + mixed "generated.local/gen/mixed" ) // BuildLookupPayload builds the payload for the Mixed lookup endpoint from CLI @@ -21,7 +22,7 @@ func BuildLookupPayload(mixedLookupBody string) (*mixed.LookupPayload, error) { { err = json.Unmarshal([]byte(mixedLookupBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Cupiditate cupiditate minus veniam sed officia qui.\",\n \"key\": \"Numquam iusto molestias nulla quod nobis molestias.\"\n }'") } } v := &mixed.LookupPayload{ diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden index af9df4cd94..56df3687b2 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden @@ -10,11 +10,12 @@ package client import ( "bytes" "context" + "errors" "io" - mixed "kitchensink/mixed" "net/http" "net/url" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" ) @@ -53,18 +54,24 @@ func EncodeLookupRequest(encoder func(*http.Request) goahttp.Encoder) func(*http // lookup endpoint. restoreBody controls whether the response body should be // restored after having been read. func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Mixed", "lookup", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -83,7 +90,10 @@ func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreB res := NewLookupResultOK(&body) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) + } return nil, goahttp.ErrInvalidResponse("Mixed", "lookup", resp.StatusCode, string(body)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/types.go.golden index 5f55e34495..a5fac14410 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/types.go.golden @@ -8,8 +8,7 @@ package client import ( - mixed "kitchensink/mixed" - + mixed "generated.local/gen/mixed" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/encode_decode.go.golden index 857b538a9a..35df2187ef 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/encode_decode.go.golden @@ -11,9 +11,9 @@ import ( "context" "errors" "io" - mixed "kitchensink/mixed" "net/http" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/server.go.golden index 0d3139d9c7..0a9fa434ca 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/server.go.golden @@ -9,9 +9,9 @@ package server import ( "context" - mixed "kitchensink/mixed" "net/http" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/types.go.golden index cdd6f15233..01151f07dc 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/types.go.golden @@ -8,8 +8,7 @@ package server import ( - mixed "kitchensink/mixed" - + mixed "generated.local/gen/mixed" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden index e6eabf22b7..8866e293ef 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden @@ -10,7 +10,8 @@ package client import ( "encoding/json" "fmt" - calc "kitchensink/calc" + + calc "generated.local/gen/calc" ) // BuildAddPayload builds the payload for the Calc add endpoint from CLI flags. @@ -20,7 +21,7 @@ func BuildAddPayload(calcAddBody string) (*calc.AddPayload, error) { { err = json.Unmarshal([]byte(calcAddBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": 281524111196350841,\n \"b\": 4865202500627059760,\n \"id\": \"Perspiciatis sed soluta distinctio facere voluptas et.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": 5718665059814127631,\n \"b\": 5455021967244844938,\n \"id\": \"Assumenda molestias optio.\"\n }'") } } v := &calc.AddPayload{ @@ -39,7 +40,7 @@ func BuildLogPayload(calcLogBody string) (*calc.LogPayload, error) { { err = json.Unmarshal([]byte(calcLogBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Suscipit saepe tempore fuga recusandae amet blanditiis.\",\n \"message\": \"Est quisquam molestiae.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Quae consectetur.\",\n \"message\": \"Quo excepturi.\"\n }'") } } v := &calc.LogPayload{ diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden index 854d337caa..922564e6cd 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden @@ -31,12 +31,12 @@ type Client struct { decoder func(*http.Response) goahttp.Decoder } -// bufferPool is a pool of bytes.Buffers for encoding requests. +// bufferPool reuses byte buffers while requests are encoded. var bufferPool = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } -// NewClient instantiates HTTP clients for all the Calc service servers. +// NewClient creates HTTP clients for all the Calc service servers. func NewClient( scheme string, host string, @@ -45,7 +45,6 @@ func NewClient( dec func(*http.Response) goahttp.Decoder, restoreBody bool, ) *Client { - return &Client{ Doer: doer, RestoreResponseBody: restoreBody, diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden index 769f589798..d729bd6005 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden @@ -10,11 +10,12 @@ package client import ( "bytes" "context" + "errors" "io" - calc "kitchensink/calc" "net/http" "net/url" + calc "generated.local/gen/calc" "github.com/google/uuid" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" @@ -63,21 +64,31 @@ func EncodeAddRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Re // service add JSON-RPC method. restoreBody controls whether the response body // should be restored after having been read. func DecodeAddResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Calc", "add", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Calc", "add", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Calc", "add", err) + } return nil, goahttp.ErrInvalidResponse("Calc", "add", resp.StatusCode, string(body)) } @@ -104,8 +115,7 @@ func DecodeAddResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody } return nil, NewAddOverflow(&body) default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Calc", "add", resp.StatusCode, string(body)) + return nil, goahttp.ErrInvalidResponse("Calc", "add", resp.StatusCode, string(jresp.Error.Data)) } } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) @@ -164,21 +174,31 @@ func EncodePingRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.R // service ping JSON-RPC method. restoreBody controls whether the response body // should be restored after having been read. func DecodePingResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Calc", "ping", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Calc", "ping", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Calc", "ping", err) + } return nil, goahttp.ErrInvalidResponse("Calc", "ping", resp.StatusCode, string(body)) } @@ -190,8 +210,7 @@ func DecodePingResponse(decoder func(*http.Response) goahttp.Decoder, restoreBod if jresp.Error != nil { switch jresp.Error.Code { default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Calc", "ping", resp.StatusCode, string(body)) + return nil, goahttp.ErrInvalidResponse("Calc", "ping", resp.StatusCode, string(jresp.Error.Data)) } } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) @@ -251,21 +270,31 @@ func EncodeLogRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Re // service log JSON-RPC method. restoreBody controls whether the response body // should be restored after having been read. func DecodeLogResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Calc", "log", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Calc", "log", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Calc", "log", err) + } return nil, goahttp.ErrInvalidResponse("Calc", "log", resp.StatusCode, string(body)) } @@ -277,8 +306,7 @@ func DecodeLogResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody if jresp.Error != nil { switch jresp.Error.Code { default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Calc", "log", resp.StatusCode, string(body)) + return nil, goahttp.ErrInvalidResponse("Calc", "log", resp.StatusCode, string(jresp.Error.Data)) } } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden index 3bdfe020a8..6e88c7eea6 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden @@ -8,8 +8,7 @@ package client import ( - calc "kitchensink/calc" - + calc "generated.local/gen/calc" goa "goa.design/goa/v3/pkg" ) @@ -130,7 +129,7 @@ func ValidateAddResponseBody(body *AddResponseBody) (err error) { } // ValidateAddOverflowResponseBody runs the validations defined on -// add_overflow_response_body +// AddOverflowResponseBody func ValidateAddOverflowResponseBody(body *AddOverflowResponseBody) (err error) { if body.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/encode_decode.go.golden index 11c6870d95..35f799af96 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/encode_decode.go.golden @@ -11,9 +11,9 @@ import ( "bytes" "errors" "io" - calc "kitchensink/calc" "net/http" + calc "generated.local/gen/calc" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden index 72f785418f..50eec75e95 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden @@ -13,9 +13,9 @@ import ( "errors" "fmt" "io" - calc "kitchensink/calc" "net/http" + calc "generated.local/gen/calc" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" @@ -61,8 +61,8 @@ func New( encoder: encoder, errhandler: errhandler, } - // Default HTTP handler per transport kind - // Plain HTTP JSON-RPC + // Install the request handler required by this service's methods. + // ServeHTTP handles ordinary JSON-RPC request bodies. s.Handler = http.HandlerFunc(s.ServeHTTP) return s } @@ -81,32 +81,44 @@ func (s *Server) MethodNames() []string { return calc.MethodNames[:] } // ServeHTTP handles JSON-RPC requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleHTTP(w, r) -} // handleHTTP handles JSON-RPC requests. +} + +// handleHTTP reads one JSON-RPC request object or one array of requests. func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { - // Peek at the first byte to determine request type - bufReader := bufio.NewReader(r.Body) - peek, err := bufReader.Peek(1) - if err != nil && err != io.EOF { - r.Body.Close() - s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", err)) - return + originalBody := r.Body + + // Find the first JSON byte so leading whitespace does not change whether the + // body is decoded as one request or an array. + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } } - // Wrap the buffered reader with the original closer - r.Body = struct { - io.Reader - io.Closer - }{ - Reader: bufReader, - Closer: r.Body, - } - defer func(r *http.Request) { - if err := r.Body.Close(); err != nil { + // The generated handler owns the original body. Decoders receive a wrapper + // whose Close method cannot close it a second time. + r.Body = io.NopCloser(bufReader) + defer func() { + if err := originalBody.Close(); err != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) } - }(r) + }() - // Route to appropriate handler + // A leading '[' starts an array of requests. if len(peek) > 0 && peek[0] == '[' { s.handleBatch(w, r) return @@ -114,11 +126,11 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { s.handleSingle(w, r) } -// handleSingle handles a single JSON-RPC request. +// handleSingle decodes and runs one JSON-RPC request. func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // JSON-RPC parse error with null id and generic message + // A request that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) @@ -128,36 +140,46 @@ func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { s.processRequest(r.Context(), r, &req, w) } -// handleBatch handles a batch of JSON-RPC requests. +// handleBatch handles an array of JSON-RPC values and writes the required responses. func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { var reqs []jsonrpc.RawRequest if err := s.decoder(r).Decode(&reqs); err != nil { - // JSON-RPC parse error for batch with null id and generic message + // An array that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) } return } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) + } + return + } - // Write responses + // Write every response into one JSON array. w.Header().Set("Content-Type", "application/json") writer := &batchWriter{Writer: w} for _, req := range reqs { - // Process the request with batch writer + // The writer inserts the array separators around each response. s.processRequest(r.Context(), r, &req, writer) } - // Close the batch array + // Write the closing bracket only when at least one request produced a response. if writer.written { - writer.Writer.Write([]byte{']'}) + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } } } -// ProcessRequest processes a single JSON-RPC request. +// processRequest validates the JSON-RPC version and method, then calls the matching handler. func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { - if req.JSONRPC != "2.0" { + if req.Invalid || req.JSONRPC != "2.0" { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) return } @@ -181,11 +203,14 @@ func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonr s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "log", err)) } default: - s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } } } -// batchWriter is a helper type that implements http.ResponseWriter for writing multiple JSON-RPC responses +// batchWriter inserts JSON array separators around responses from one request +// array. type batchWriter struct { io.Writer header http.Header @@ -208,18 +233,20 @@ func (rb *batchWriter) WriteHeader(statusCode int) { } func (rb *batchWriter) Write(data []byte) (int, error) { + separator := byte(',') if !rb.written { - rb.written = true - rb.Writer.Write([]byte{'['}) - } else { - rb.Writer.Write([]byte{','}) + separator = '[' + } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err } + rb.written = true return rb.Writer.Write(data) } // Mount configures the mux to serve the JSON-RPC Calc service methods. func Mount(mux goahttp.Muxer, h *Server) { - // HTTP only + // This server handles ordinary JSON-RPC request bodies. mux.Handle("POST", "/rpc", h.ServeHTTP) } @@ -243,15 +270,10 @@ func NewAddHandler( ctx = context.WithValue(ctx, goa.ServiceKey, "Calc") params, err := decodeParams(r, req) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) } return nil @@ -261,35 +283,26 @@ func NewAddHandler( } res, err := endpoint(ctx, params) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { + if req.HasID { var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { - case "overflow": - encodeJSONRPCError(ctx, w, req, -32602, err.Error(), err, encoder, errhandler) - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams + if errors.As(err, &en) { + switch en.GoaErrorName() { + case "overflow": + encodeJSONRPCError(ctx, w, req, -32602, err.Error(), err, encoder, errhandler) + return nil } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil } - - // For methods with no result, check if this is a notification + if !req.HasID { + // A notification has no ID field and receives no response. + return nil + } // For methods with results, determine the ID to use for the response var id any @@ -301,13 +314,8 @@ func NewAddHandler( id = req.ID } - if id == nil || id == "" { - // Notification - no response - return nil - } - // Send response with the result - // Convert result to response body with proper JSON tags + // Build the response body with the fields and JSON names declared by the service. body := NewAddResponseBody(res.(*calc.AddResult)) response := jsonrpc.MakeSuccessResponse(id, body) if err := encoder(ctx, w).Encode(response); err != nil { @@ -331,46 +339,26 @@ func NewPingHandler( ctx = context.WithValue(ctx, goa.ServiceKey, "Calc") res, err := endpoint(ctx, nil) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil } - - // For methods with no result, check if this is a notification + if !req.HasID { + // A notification has no ID field and receives no response. + return nil + } // For methods with results, determine the ID to use for the response var id any // No ID field in result - use request ID id = req.ID - if id == nil || id == "" { - // Notification - no response - return nil - } - // Send response with the result - // Convert result to response body with proper JSON tags + // Build the response body with the fields and JSON names declared by the service. body := NewPingResponseBody(res.(*calc.PingResult)) response := jsonrpc.MakeSuccessResponse(id, body) if err := encoder(ctx, w).Encode(response); err != nil { @@ -395,15 +383,10 @@ func NewLogHandler( ctx = context.WithValue(ctx, goa.ServiceKey, "Calc") params, err := decodeParams(r, req) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) } return nil @@ -414,38 +397,19 @@ func NewLogHandler( } _, err = endpoint(ctx, params) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil } - - // For methods with no result, check if this is a notification - if req.ID == nil || req.ID == "" { - // Notification - no response + if !req.HasID { + // A notification has no ID field and receives no response. return nil } - // Request with no result - send empty success response + // A method with no result returns a JSON null result. response := jsonrpc.MakeSuccessResponse(req.ID, nil) if err := encoder(ctx, w).Encode(response); err != nil { errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) @@ -454,14 +418,14 @@ func NewLogHandler( } } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func encodeJSONRPCError( ctx context.Context, w http.ResponseWriter, @@ -472,10 +436,8 @@ func encodeJSONRPCError( encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), ) { - if req.ID != nil { - response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) - if err := encoder(ctx, w).Encode(response); err != nil { - errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) - } + response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden index 2373a233dc..c8ea49b12a 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden @@ -8,8 +8,7 @@ package server import ( - calc "kitchensink/calc" - + calc "generated.local/gen/calc" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden deleted file mode 100644 index f2dcb9b86c..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden +++ /dev/null @@ -1,33 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC client CLI support package -// -// Command: -// goa - -package client - -import ( - "encoding/json" - "fmt" - chat "kitchensink/chat" -) - -// BuildEchoPayload builds the payload for the Chat echo endpoint from CLI -// flags. -func BuildEchoPayload(chatEchoBody string) (*chat.EchoPayload, error) { - var err error - var body EchoStreamingBody - { - err = json.Unmarshal([]byte(chatEchoBody), &body) - if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Nemo consequuntur est odio.\",\n \"msg\": \"Accusantium mollitia id sapiente ratione.\"\n }'") - } - } - v := &chat.EchoPayload{ - ID: body.ID, - Msg: body.Msg, - } - - return v, nil -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden deleted file mode 100644 index b0c1f21da4..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden +++ /dev/null @@ -1,178 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat client JSON-RPC transport -// -// Command: -// goa - -package client - -import ( - "context" - "net/http" - "sync" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" -) - -// Client lists the Chat service endpoint HTTP clients. -type Client struct { - // Doer is the HTTP client used to make requests to the Chat service. - Doer goahttp.Doer - // RestoreResponseBody controls whether the response bodies are reset after - // decoding so they can be read again. - RestoreResponseBody bool - - scheme string - host string - encoder func(*http.Request) goahttp.Encoder - decoder func(*http.Response) goahttp.Decoder - dialer goahttp.Dialer - configfn goahttp.ConnConfigureFunc - - connMu sync.RWMutex - conn *websocket.Conn - closed atomic.Bool - - // Stream configuration (shared by all WebSocket streams) - streamConfig *jsonrpc.StreamConfig -} - -// NewClient instantiates HTTP clients for all the Chat service servers. -func NewClient( - scheme string, - host string, - doer goahttp.Doer, - enc func(*http.Request) goahttp.Encoder, - dec func(*http.Response) goahttp.Decoder, - restoreBody bool, - dialer goahttp.Dialer, - cfn goahttp.ConnConfigureFunc, - streamOpts ...jsonrpc.StreamConfigOption, -) *Client { - // Create stream configuration from options - streamConfig := jsonrpc.NewStreamConfig(streamOpts...) - - return &Client{ - Doer: doer, - RestoreResponseBody: restoreBody, - scheme: scheme, - host: host, - decoder: dec, - encoder: enc, - dialer: dialer, - configfn: cfn, - streamConfig: streamConfig, - } -} - -// Echo returns an endpoint that makes JSON-RPC requests to the Chat service -// echo method. -func (c *Client) Echo() goa.Endpoint { - return func(ctx context.Context, v any) (any, error) { - // For WebSocket, pass the base decoder to the stream and decode inner results - decodeResponse := c.decoder - - // Get direct WebSocket connection - ws, err := c.getConn(ctx) - if err != nil { - return nil, err - } - - // Create context with cancellation for the stream - streamCtx, cancel := context.WithCancel(ctx) - - // Create the stream with direct WebSocket handling - stream := &EchoClientStream{ - ws: ws, - ctx: streamCtx, - cancel: cancel, - done: make(chan struct{}), - config: c.streamConfig, - decoder: decodeResponse, - } - - // Start background response handler - go stream.responseHandler() - - return stream, nil - } -} - -// getConn returns the current WebSocket connection or creates a new one -func (c *Client) getConn(ctx context.Context) (*websocket.Conn, error) { - c.connMu.RLock() - conn := c.conn - if conn != nil { - // Check if connection is still alive - if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err == nil { - c.connMu.RUnlock() - return conn, nil - } - // Connection is dead, need new one - } - c.connMu.RUnlock() - - // Create new connection - c.connMu.Lock() - defer c.connMu.Unlock() - - // Double-check after acquiring write lock - if c.conn != nil { - if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err == nil { - return c.conn, nil - } - // Close the dead connection - c.conn.Close() - } - - // Convert scheme for WebSocket - wsScheme := "ws" - if c.scheme == "https" { - wsScheme = "wss" - } - - // Find the WebSocket path from the service endpoints - url := wsScheme + "://" + c.host + "/ws/ws" - - ws, _, err := c.dialer.DialContext(ctx, url, nil) - if err != nil { - return nil, goahttp.ErrRequestError("Chat", "connect", err) - } - - if c.configfn != nil { - ws = c.configfn(ws, nil) - } - - // Store the direct WebSocket connection - c.conn = ws - - return c.conn, nil -} - -// Close closes the WebSocket connection and marks the client as closed -func (c *Client) Close() error { - if c.closed.Swap(true) { - return nil // Already closed - } - - c.connMu.Lock() - defer c.connMu.Unlock() - - if c.conn != nil { - err := c.conn.Close() - c.conn = nil - return err - } - return nil -} - -// IsClosed returns true if the client connection has been closed -func (c *Client) IsClosed() bool { - return c.closed.Load() -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden deleted file mode 100644 index 7df8db7d95..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden +++ /dev/null @@ -1,115 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC client encoders and decoders -// -// Command: -// goa - -package client - -import ( - "bytes" - "context" - "io" - chat "kitchensink/chat" - "net/http" - "net/url" - - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" -) - -// BuildEchoRequest instantiates a HTTP request object with method and path set -// to call the "Chat" service "echo" endpoint -func (c *Client) BuildEchoRequest(ctx context.Context, v any) (*http.Request, error) { - scheme := c.scheme - switch c.scheme { - case "http": - scheme = "ws" - case "https": - scheme = "wss" - } - u := &url.URL{Scheme: scheme, Host: c.host, Path: EchoChatPath()} - req, err := http.NewRequest("GET", u.String(), nil) - if err != nil { - return nil, goahttp.ErrInvalidURL("Chat", "echo", u.String(), err) - } - if ctx != nil { - req = req.WithContext(ctx) - } - - return req, nil -} - -// EncodeEchoRequest returns an encoder for requests sent to the Chat echo -// server. -func EncodeEchoRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { - return func(req *http.Request, v any) error { - p, ok := v.(*chat.EchoPayload) - if !ok { - return goahttp.ErrInvalidType("Chat", "echo", "*chat.EchoPayload", v) - } - b := NewEchoStreamingBody(p) - body := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "echo", - Params: b, - } - if p.ID != nil && *p.ID != "" { - body.ID = p.ID - } - // If ID is nil or empty, this is a notification - no ID field - if err := encoder(req).Encode(&body); err != nil { - return goahttp.ErrEncodingError("Chat", "echo", err) - } - return nil - } -} - -// DecodeEchoResponse returns a decoder for responses returned by the Chat -// service echo JSON-RPC method. restoreBody controls whether the response body -// should be restored after having been read. -func DecodeEchoResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Chat", "echo", resp.StatusCode, string(body)) - } - - var jresp jsonrpc.RawResponse - if err := decoder(resp).Decode(&jresp); err != nil { - return nil, goahttp.ErrDecodingError("Chat", "echo", err) - } - - if jresp.Error != nil { - switch jresp.Error.Code { - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Chat", "echo", resp.StatusCode, string(body)) - } - } - resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) - var ( - body EchoResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("Chat", "echo", err) - } - res := NewEchoResultOK(&body) - return res, nil - } -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/paths.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/paths.go.golden deleted file mode 100644 index 8122b4ec17..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/paths.go.golden +++ /dev/null @@ -1,13 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// JSON-RPC request path constructors for the Chat service. -// -// Command: -// goa - -package client - -// EchoChatPath returns the URL path to the Chat service echo HTTP endpoint. -func EchoChatPath() string { - return "/ws/ws" -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden deleted file mode 100644 index 73b4ab7475..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden +++ /dev/null @@ -1,49 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC client types -// -// Command: -// goa - -package client - -import ( - chat "kitchensink/chat" -) - -// EchoStreamingBody is the type of the "Chat" service "echo" endpoint HTTP -// request body. -type EchoStreamingBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Msg *string `form:"msg,omitempty" json:"msg,omitempty" xml:"msg,omitempty"` -} - -// EchoResponseBody is the type of the "Chat" service "echo" endpoint HTTP -// response body. -type EchoResponseBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Echo *string `form:"echo,omitempty" json:"echo,omitempty" xml:"echo,omitempty"` -} - -// NewEchoStreamingBody builds the HTTP request body from the payload of the -// "echo" endpoint of the "Chat" service. -func NewEchoStreamingBody(p *chat.EchoPayload) *EchoStreamingBody { - body := &EchoStreamingBody{ - ID: p.ID, - Msg: p.Msg, - } - return body -} - -// NewEchoResultOK builds a "Chat" service "echo" endpoint result from a HTTP -// "OK" response. -func NewEchoResultOK(body *EchoResponseBody) *chat.EchoResult { - v := &chat.EchoResult{ - ID: body.ID, - Echo: body.Echo, - } - - return v -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden deleted file mode 100644 index 474ec665dc..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden +++ /dev/null @@ -1,346 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat WebSocket JSON-RPC client -// -// Command: -// goa - -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - chat "kitchensink/chat" - "net/http" - "strconv" - "sync" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" -) - -// Stream error types for comprehensive error reporting -type StreamErrorType int - -const ( - StreamErrorConnection StreamErrorType = iota // WebSocket connection errors - StreamErrorProtocol // Invalid JSON-RPC protocol - StreamErrorParsing // Failed to parse/decode response - StreamErrorOrphaned // Response with no matching request - StreamErrorTimeout // Request timeout -) - -// StreamErrorHandler allows users to handle stream errors -type StreamErrorHandler func(ctx context.Context, errorType StreamErrorType, err error, response *jsonrpc.RawResponse) - -// EchoClientStream implements the echo client stream with direct WebSocket -// handling. -type EchoClientStream struct { - // Direct WebSocket transport - ws *websocket.Conn - writeMu sync.Mutex // Serialize WebSocket writes - - // JSON-RPC correlation - pending sync.Map // map[jsonrpcID]*EchoClientStreamPendingRequest - idGenerator atomic.Uint64 // JSON-RPC request ID generator - - // Lifecycle management - ctx context.Context - cancel context.CancelFunc - done chan struct{} // Signals stream closure - closeOnce sync.Once - - // Error handling - errorOnce sync.Once - lastError atomic.Value // Last error encountered - - // Stream configuration - config *jsonrpc.StreamConfig // Stream configuration options - decoder func(*http.Response) goahttp.Decoder // User-provided decoder for result bodies -} - -// Stream-specific types for EchoClientStream -type EchoClientStreamPendingRequest struct { - userID string // User-provided payload ID - resultChan chan EchoClientStreamStreamResult // Buffered result delivery - timeout *time.Timer // Request timeout handling -} - -type EchoClientStreamStreamResult struct { - result *chat.EchoResult - err error -} - -// Send sends streaming data to the echo endpoint with dual ID correlation. -func (s *EchoClientStream) Send(v *chat.EchoPayload) error { - return s.SendWithContext(s.ctx, v) -} - -// SendWithContext sends streaming data to the echo endpoint with context. -func (s *EchoClientStream) SendWithContext(ctx context.Context, v *chat.EchoPayload) error { - // Check for stream-level errors first - if err := s.getError(); err != nil { - return err - } - // Honor user-provided ID or generate one - userID := "" - // Honor user-provided ID if it exists in the payload - userID = s.generateUserID() - - // Generate JSON-RPC protocol ID - jsonrpcID := strconv.FormatUint(s.idGenerator.Add(1), 10) - // Create pending request tracking for bidirectional streaming - pending := &EchoClientStreamPendingRequest{ - userID: userID, - resultChan: make(chan EchoClientStreamStreamResult, s.config.ResultChannelBuffer), - timeout: time.NewTimer(s.config.RequestTimeout), - } - - s.pending.Store(jsonrpcID, pending) - - // Construct JSON-RPC request - request := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "echo", - Params: v, - ID: &jsonrpcID, - } - - // Send with write protection - s.writeMu.Lock() - err := s.ws.WriteJSON(request) - s.writeMu.Unlock() - - if err != nil { - s.pending.Delete(jsonrpcID) - pending.timeout.Stop() - s.setError(err) - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, err, nil) - return fmt.Errorf("failed to send request: %w", err) - } - - return nil -} - -// Recv receives streaming data from the echo endpoint. -func (s *EchoClientStream) Recv() (*chat.EchoResult, error) { - return s.RecvWithContext(s.ctx) -} - -// RecvWithContext receives streaming data from the echo endpoint with context. -func (s *EchoClientStream) RecvWithContext(ctx context.Context) (*chat.EchoResult, error) { - // Check for stream-level errors first - if err := s.getError(); err != nil { - return nil, err - } - // Find the oldest pending request (FIFO ordering) - var oldestPending *EchoClientStreamPendingRequest - var oldestKey string - - s.pending.Range(func(key, value any) bool { - pending := value.(*EchoClientStreamPendingRequest) - if oldestPending == nil { - oldestPending = pending - oldestKey = key.(string) - } - return false // Take first one for FIFO - }) - - if oldestPending == nil { - return nil, fmt.Errorf("no pending requests - call Send() first") - } - - // Wait for result with context cancellation - select { - case result := <-oldestPending.resultChan: - s.pending.Delete(oldestKey) - oldestPending.timeout.Stop() - return result.result, result.err - - case <-oldestPending.timeout.C: - s.pending.Delete(oldestKey) - timeoutErr := fmt.Errorf("request timeout after %v", s.config.RequestTimeout) - // Report timeout errors - s.handleError(jsonrpc.StreamErrorTimeout, timeoutErr, nil) - return nil, timeoutErr - - case <-ctx.Done(): - return nil, ctx.Err() - - case <-s.done: - if err := s.getError(); err != nil { - return nil, err - } - return nil, fmt.Errorf("stream closed") - } -} - -// responseHandler processes incoming WebSocket messages in a background goroutine -func (s *EchoClientStream) responseHandler() { - defer close(s.done) - - for { - select { - case <-s.ctx.Done(): - s.cleanupPendingRequests(s.ctx.Err()) - return - default: - var response jsonrpc.RawResponse - if err := s.ws.ReadJSON(&response); err != nil { - connectionErr := fmt.Errorf("failed to read response: %w", err) - s.setError(connectionErr) - - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, connectionErr, nil) - - s.cleanupPendingRequests(connectionErr) - return - } - - s.handleResponse(&response) - } - } -} - -func (s *EchoClientStream) handleResponse(response *jsonrpc.RawResponse) { - if response.ID == nil { - // This is a server-initiated notification - // For now, just report it as an event via the error handler - // In the future, we could add a dedicated notification handler - if s.config.ErrorHandler != nil { - s.config.ErrorHandler(s.ctx, jsonrpc.StreamErrorNotification, - fmt.Errorf("received server notification"), response) - } - return - } - - jsonrpcID := response.ID - pendingInterface, exists := s.pending.LoadAndDelete(jsonrpcID) - if !exists { - // Orphaned response - report to error handler - s.handleError(jsonrpc.StreamErrorOrphaned, fmt.Errorf("received response for unknown ID: %s", jsonrpcID), response) - return - } - - pending := pendingInterface.(*EchoClientStreamPendingRequest) - pending.timeout.Stop() - - var result EchoClientStreamStreamResult - - if response.Error != nil { - result.err = response.Error - // Report protocol-level JSON-RPC errors - s.handleError(jsonrpc.StreamErrorProtocol, response.Error, response) - } else { - // Use generated decoder for consistent response parsing - parsedResult, err := s.decodeResponse(response.Result) - if err != nil { - result.err = fmt.Errorf("failed to decode response: %w", err) - // Report parsing errors - s.handleError(jsonrpc.StreamErrorParsing, err, response) - } else { - // Backfill the result ID from the envelope when missing - if parsedResult.ID == nil || *parsedResult.ID == "" { - idCopy := jsonrpc.IDToString(response.ID) - parsedResult.ID = &idCopy - } - result.result = parsedResult - } - } - - // Non-blocking send to result channel - select { - case pending.resultChan <- result: - default: - // Channel full - should not happen with buffer size 1 - } -} - -// Helper methods -func (s *EchoClientStream) generateUserID() string { - return fmt.Sprintf("user-%d-%d", time.Now().UnixNano(), s.idGenerator.Load()) -} - -// handleError calls the user-provided error handler if available -func (s *EchoClientStream) handleError(errorType jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { - if s.config.ErrorHandler != nil { - s.config.ErrorHandler(s.ctx, errorType, err, response) - } -} - -// decodeResponse decodes JSON-RPC response data using the user-provided decoder -func (s *EchoClientStream) decodeResponse(data json.RawMessage) (*chat.EchoResult, error) { - // Create minimal HTTP response with raw JSON data for user's decoder - resp := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader(data)), - } - - // Use user-provided decoder to decode the result (expects inner result JSON) - dec := s.decoder(resp) - var out *chat.EchoResult - if err := dec.Decode(&out); err != nil { - return nil, err - } - return out, nil -} - -func (s *EchoClientStream) setError(err error) { - s.errorOnce.Do(func() { - s.lastError.Store(err) - s.cancel() // Cancel context to signal error state - }) -} - -func (s *EchoClientStream) getError() error { - if err, ok := s.lastError.Load().(error); ok { - return err - } - return nil -} - -func (s *EchoClientStream) cleanupPendingRequests(err error) { - s.pending.Range(func(key, value any) bool { - pending := value.(*EchoClientStreamPendingRequest) - pending.timeout.Stop() - - select { - case pending.resultChan <- EchoClientStreamStreamResult{err: err}: - default: - } - - s.pending.Delete(key) - return true - }) -} - -// Close closes the stream and cleans up resources. -func (s *EchoClientStream) Close() error { - var err error - s.closeOnce.Do(func() { - s.cancel() - - // Wait for response handler to finish - select { - case <-s.done: - case <-time.After(s.config.CloseTimeout): - // Force close if handler doesn't respond - } - - // Clean up any remaining pending requests - s.cleanupPendingRequests(fmt.Errorf("stream closed")) - - // Close the WebSocket connection - if s.ws != nil { - err = s.ws.Close() - } - }) - return err -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden deleted file mode 100644 index 2678df7e92..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden +++ /dev/null @@ -1,47 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC server encoders and decoders -// -// Command: -// goa - -package server - -import ( - "bytes" - "errors" - "io" - chat "kitchensink/chat" - "net/http" - - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" -) - -// DecodeEchoRequest returns a decoder for requests sent to the Chat echo -// endpoint. -func DecodeEchoRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*chat.EchoPayload, error) { - return func(r *http.Request, req *jsonrpc.RawRequest) (*chat.EchoPayload, error) { - r.Body = io.NopCloser(bytes.NewReader(req.Params)) - var payload *chat.EchoPayload - var ( - body EchoStreamingBody - err error - ) - err = decoder(r).Decode(&body) - if err != nil { - if errors.Is(err, io.EOF) { - return payload, goa.MissingPayloadError() - } - var gerr *goa.ServiceError - if errors.As(err, &gerr) { - return payload, gerr - } - return payload, goa.DecodePayloadError(err.Error()) - } - payload = NewEchoPayload(&body) - - return payload, nil - } -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/paths.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/paths.go.golden deleted file mode 100644 index b2ebfec66b..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/paths.go.golden +++ /dev/null @@ -1,13 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// JSON-RPC request path constructors for the Chat service. -// -// Command: -// goa - -package server - -// EchoChatPath returns the URL path to the Chat service echo HTTP endpoint. -func EchoChatPath() string { - return "/ws/ws" -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden deleted file mode 100644 index 6d511d6e1b..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden +++ /dev/null @@ -1,140 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC server -// -// Command: -// goa - -package server - -import ( - "context" - "fmt" - chat "kitchensink/chat" - "net/http" - - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" -) - -// Server handles JSON-RPC requests for the Chat service. -type Server struct { - http.Handler - // Methods is the list of methods served by this server. - Methods []string - // StreamHandler is the handler for the streaming service. - StreamHandler func(context.Context, chat.Stream) error - - echo func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) - echoEndpoint goa.Endpoint - - decoder func(*http.Request) goahttp.Decoder - encoder func(context.Context, http.ResponseWriter) goahttp.Encoder - errhandler func(context.Context, http.ResponseWriter, error) - upgrader goahttp.Upgrader - configfn goahttp.ConnConfigureFunc -} - -// New creates a JSON-RPC server which loads HTTP requests and calls the "Chat" -// service methods. -func New( - streamHandler func(context.Context, chat.Stream) error, - endpoints *chat.Endpoints, - mux goahttp.Muxer, - decoder func(*http.Request) goahttp.Decoder, - encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, - errhandler func(context.Context, http.ResponseWriter, error), - upgrader goahttp.Upgrader, - configfn goahttp.ConnConfigureFunc, -) *Server { - s := &Server{ - Methods: []string{ - "echo", - }, - StreamHandler: streamHandler, - echo: NewEchoHandler(endpoints.Echo, mux, decoder), - echoEndpoint: endpoints.Echo, - decoder: decoder, - encoder: encoder, - errhandler: errhandler, - upgrader: upgrader, - configfn: configfn, - } - // Default HTTP handler per transport kind - // WebSocket services implement ServeHTTP for upgrade - s.Handler = http.HandlerFunc(s.ServeHTTP) - return s -} - -// Service returns the name of the service served. -func (s *Server) Service() string { return "Chat" } - -// Use wraps the server handlers with the given middleware. -func (s *Server) Use(m func(http.Handler) http.Handler) { - s.Handler = m(s.Handler) -} - -// MethodNames returns the methods served. -func (s *Server) MethodNames() []string { return chat.MethodNames[:] } - -// ServeHTTP handles WebSocket JSON-RPC requests. -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithCancel(r.Context()) - conn, err := s.upgrader.Upgrade(w, r, nil) - if err != nil { - s.errhandler(r.Context(), w, fmt.Errorf("failed to upgrade to WebSocket: %w", err)) - cancel() - return - } - if s.configfn != nil { - conn = s.configfn(conn, cancel) - } - defer conn.Close() - - stream := &chatStream{ - echo: s.echo, - echoEndpoint: s.echoEndpoint, - r: r, - w: w, - conn: conn, - cancel: cancel, - } - s.StreamHandler(ctx, stream) -} - -// Mount configures the mux to serve the JSON-RPC Chat service methods. -func Mount(mux goahttp.Muxer, h *Server) { - // HTTP only - mux.Handle("GET", "/ws/ws", h.ServeHTTP) -} - -// Mount configures the mux to serve the JSON-RPC Chat service methods. -func (s *Server) Mount(mux goahttp.Muxer) { - Mount(mux, s) -} - -// NewEchoHandler creates a JSON-RPC handler which calls the "Chat" service -// "echo" endpoint. -func NewEchoHandler( - endpoint goa.Endpoint, - mux goahttp.Muxer, - decoder func(*http.Request) goahttp.Decoder, -) func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) { - return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest) (any, error) { - ctx = context.WithValue(ctx, goa.MethodKey, "echo") - ctx = context.WithValue(ctx, goa.ServiceKey, "Chat") - decodeParams := DecodeEchoRequest(mux, decoder) - params, err := decodeParams(r, req) - if err != nil { - return nil, err - } - if req.ID != nil { - idStr := jsonrpc.IDToString(req.ID) - params.ID = &idStr - } - // For bidirectional streaming, we need to return the payload - // The actual streaming will be handled when the stream is passed to the endpoint - return params, nil - } -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden deleted file mode 100644 index 7ec0943aed..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC server types -// -// Command: -// goa - -package server - -import ( - chat "kitchensink/chat" -) - -// EchoStreamingBody is the type of the "Chat" service "echo" endpoint HTTP -// request body. -type EchoStreamingBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Msg *string `form:"msg,omitempty" json:"msg,omitempty" xml:"msg,omitempty"` -} - -// EchoResponseBody is the type of the "Chat" service "echo" endpoint HTTP -// response body. -type EchoResponseBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Echo *string `form:"echo,omitempty" json:"echo,omitempty" xml:"echo,omitempty"` -} - -// NewEchoResponseBody builds the HTTP response body from the result of the -// "echo" endpoint of the "Chat" service. -func NewEchoResponseBody(res *chat.EchoResult) *EchoResponseBody { - body := &EchoResponseBody{ - ID: res.ID, - Echo: res.Echo, - } - return body -} - -// NewEchoPayload builds a Chat service echo endpoint payload. -func NewEchoPayload(body *EchoStreamingBody) *chat.EchoPayload { - v := &chat.EchoPayload{ - ID: body.ID, - Msg: body.Msg, - } - - return v -} - -// NewEchoStreamingBody builds a Chat service echo endpoint payload. -func NewEchoStreamingBody(body *EchoStreamingBody) *chat.EchoPayload { - v := &chat.EchoPayload{ - ID: body.ID, - Msg: body.Msg, - } - - return v -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden deleted file mode 100644 index 6391bc1329..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden +++ /dev/null @@ -1,192 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat WebSocket server streaming -// -// Command: -// goa - -package server - -import ( - "context" - "fmt" - chat "kitchensink/chat" - "net/http" - "time" - - "github.com/gorilla/websocket" - "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" -) - -// chatStream implements the Stream interface. -type chatStream struct { - // echo decodes requests for the echo method - echo func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) - // echoEndpoint is the endpoint for the echo method - echoEndpoint goa.Endpoint - // cancel is the context cancellation function which cancels the request - // context when invoked. - cancel context.CancelFunc - // w is the HTTP response writer used in upgrading the connection. - w http.ResponseWriter - // r is the HTTP request. - r *http.Request - // conn is the underlying websocket connection. - conn *websocket.Conn -} - -// echoStreamWrapper wraps the JSON-RPC stream to provide a method-specific interface. -type echoStreamWrapper struct { - stream *chatStream - requestID any // Store the JSON-RPC request ID for responses -} - -// SendNotification sends a notification to the client (no response expected). -func (w *echoStreamWrapper) SendNotification(ctx context.Context, res *chat.EchoResult) error { - return w.stream.SendEchoNotification(ctx, res) -} - -// SendResponse sends a response to the client for the original request. -func (w *echoStreamWrapper) SendResponse(ctx context.Context, res *chat.EchoResult) error { - return w.stream.SendEchoResponse(ctx, w.requestID, res) -} - -// SendError sends an error response to the client. -func (w *echoStreamWrapper) SendError(ctx context.Context, err error) error { - return w.stream.SendError(ctx, w.requestID, err) -} - -// Close closes the underlying JSON-RPC stream. -func (w *echoStreamWrapper) Close() error { - return w.stream.Close() -} - -// SendEchoNotification sends a JSON-RPC notification for the echo method. -func (s *chatStream) SendEchoNotification(ctx context.Context, result *chat.EchoResult) error { - body := NewEchoResponseBody(result) - return s.conn.WriteJSON(jsonrpc.MakeNotification("echo", body)) -} - -// SendEchoResponse sends a JSON-RPC response for the echo method. -func (s *chatStream) SendEchoResponse(ctx context.Context, id any, result *chat.EchoResult) error { - body := NewEchoResponseBody(result) - return s.conn.WriteJSON(jsonrpc.MakeSuccessResponse(id, body)) -} - -// SendError streams JSON-RPC errors. -func (s *chatStream) SendError(ctx context.Context, id any, err error) error { - // No custom errors defined - check if it's a validation error, otherwise use internal error - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) -} - -// send writes a JSON-RPC response to the websocket connection. -func (s *chatStream) send(id any, method string, result any) error { - // If there's no ID, send as a notification instead of a response - // A JSON-RPC result with no ID is invalid per the spec - if id == nil || id == "" { - return s.conn.WriteJSON(jsonrpc.MakeNotification(method, result)) - } - return s.conn.WriteJSON(jsonrpc.MakeSuccessResponse(id, result)) -} - -// sendError sends a JSON-RPC error response to the websocket connection. -func (s *chatStream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { - response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.conn.WriteJSON(response) -} - -// Recv reads JSON-RPC requests from the Chat service stream. -func (s *chatStream) Recv(ctx context.Context) error { - var req jsonrpc.RawRequest - if err := s.conn.ReadJSON(&req); err != nil { - // Handle different types of errors gracefully - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - // Network/connection errors - terminate connection - return err - } - - // JSON parse errors - send Parse Error response and continue - if err := s.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { - // If we can't send error response, connection is broken - return fmt.Errorf("failed to send parse error: %w", err) - } - // Continue processing after sending parse error - return nil - } - return s.processRequest(ctx, &req) -} - -func (s *chatStream) processRequest(ctx context.Context, req *jsonrpc.RawRequest) error { - if req.JSONRPC != "2.0" { - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) - } - return nil - } - - if req.Method == "" { - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) - } - return nil - } - - switch req.Method { - case "echo": - // Bidirectional streaming: decode payload and create stream wrapper - payload, err := s.echo(ctx, s.r, req) - if err != nil { - return fmt.Errorf("handler error for %s: %w", "echo", err) - } - // Create wrapper that implements the method-specific stream interface - streamWrapper := &echoStreamWrapper{ - stream: s, - requestID: req.ID, - } - // Call the endpoint with payload and stream wrapper - endpointInput := &chat.EchoEndpointInput{ - Payload: payload.(*chat.EchoPayload), - Stream: streamWrapper, - } - if _, err := s.echoEndpoint(ctx, endpointInput); err != nil { - // For streaming endpoints, send error as JSON-RPC error response - if req.HasID { - // Send error response to client - if sendErr := streamWrapper.SendError(ctx, err); sendErr != nil { - return fmt.Errorf("failed to send error response: %w", sendErr) - } - // Continue processing other requests - return nil - } - // For notifications (no ID), just log and continue - return nil - } - return nil - default: - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil) - } - return nil - } -} - -// Close closes the Chat service websocket connection. -func (s *chatStream) Close() error { - var err error - if s.conn == nil { - return nil - } - if err = s.conn.WriteControl( - websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "server closing connection"), - time.Now().Add(time.Second), - ); err != nil { - return err - } - return s.conn.Close() -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden index 79aaaa6d12..90c70e13ff 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden @@ -10,13 +10,12 @@ package cli import ( "flag" "fmt" - calcc "kitchensink/jsonrpc/calc/client" - chatc "kitchensink/jsonrpc/chat/client" - feedc "kitchensink/jsonrpc/feed/client" - mixedc "kitchensink/jsonrpc/mixed/client" "net/http" "os" + calcc "generated.local/gen/jsonrpc/calc/client" + feedc "generated.local/gen/jsonrpc/feed/client" + mixedc "generated.local/gen/jsonrpc/mixed/client" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) @@ -27,18 +26,16 @@ import ( func UsageCommands() []string { return []string{ "calc (add|ping|log)", - "chat echo", - "feed watch", + "feed (watch|snapshot)", "mixed lookup", } } // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { - return os.Args[0] + " " + "calc add --body '{\n \"a\": 281524111196350841,\n \"b\": 4865202500627059760,\n \"id\": \"Perspiciatis sed soluta distinctio facere voluptas et.\"\n }'" + "\n" + - os.Args[0] + " " + "chat echo --body '{\n \"id\": \"Nemo consequuntur est odio.\",\n \"msg\": \"Accusantium mollitia id sapiente ratione.\"\n }'" + "\n" + - os.Args[0] + " " + "feed watch --body '{\n \"last_event_id\": \"Iure dolor.\",\n \"request_id\": \"Aut voluptas.\"\n }'" + "\n" + - os.Args[0] + " " + "mixed lookup --body '{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'" + "\n" + + return os.Args[0] + " " + "calc add --body '{\n \"a\": 5718665059814127631,\n \"b\": 5455021967244844938,\n \"id\": \"Assumenda molestias optio.\"\n }'" + "\n" + + os.Args[0] + " " + "feed watch --body '{\n \"last_event_id\": \"Aliquam eius.\",\n \"request_id\": \"Fugit laborum dignissimos dolore.\"\n }'" + "\n" + + os.Args[0] + " " + "mixed lookup --body '{\n \"id\": \"Et rerum porro qui explicabo ut.\",\n \"key\": \"Earum amet voluptatum ad soluta.\"\n }'" + "\n" + "" } @@ -50,8 +47,6 @@ func ParseEndpoint( enc func(*http.Request) goahttp.Encoder, dec func(*http.Response) goahttp.Decoder, restore bool, - dialer goahttp.Dialer, - chatConfigFn goahttp.ConnConfigureFunc, ) (goa.Endpoint, any, error) { var ( calcFlags = flag.NewFlagSet("calc", flag.ContinueOnError) @@ -64,16 +59,14 @@ func ParseEndpoint( calcLogFlags = flag.NewFlagSet("log", flag.ExitOnError) calcLogBodyFlag = calcLogFlags.String("body", "REQUIRED", "") - chatFlags = flag.NewFlagSet("chat", flag.ContinueOnError) - - chatEchoFlags = flag.NewFlagSet("echo", flag.ExitOnError) - chatEchoBodyFlag = chatEchoFlags.String("body", "REQUIRED", "") - feedFlags = flag.NewFlagSet("feed", flag.ContinueOnError) feedWatchFlags = flag.NewFlagSet("watch", flag.ExitOnError) feedWatchBodyFlag = feedWatchFlags.String("body", "REQUIRED", "") + feedSnapshotFlags = flag.NewFlagSet("snapshot", flag.ExitOnError) + feedSnapshotBodyFlag = feedSnapshotFlags.String("body", "REQUIRED", "") + mixedFlags = flag.NewFlagSet("mixed", flag.ContinueOnError) mixedLookupFlags = flag.NewFlagSet("lookup", flag.ExitOnError) @@ -84,11 +77,9 @@ func ParseEndpoint( calcPingFlags.Usage = calcPingUsage calcLogFlags.Usage = calcLogUsage - chatFlags.Usage = chatUsage - chatEchoFlags.Usage = chatEchoUsage - feedFlags.Usage = feedUsage feedWatchFlags.Usage = feedWatchUsage + feedSnapshotFlags.Usage = feedSnapshotUsage mixedFlags.Usage = mixedUsage mixedLookupFlags.Usage = mixedLookupUsage @@ -110,8 +101,6 @@ func ParseEndpoint( switch svcn { case "calc": svcf = calcFlags - case "chat": - svcf = chatFlags case "feed": svcf = feedFlags case "mixed": @@ -144,18 +133,14 @@ func ParseEndpoint( } - case "chat": - switch epn { - case "echo": - epf = chatEchoFlags - - } - case "feed": switch epn { case "watch": epf = feedWatchFlags + case "snapshot": + epf = feedSnapshotFlags + } case "mixed": @@ -197,19 +182,15 @@ func ParseEndpoint( endpoint = c.Log() data, err = calcc.BuildLogPayload(*calcLogBodyFlag) } - case "chat": - c := chatc.NewClient(scheme, host, doer, enc, dec, restore, dialer, chatConfigFn) - switch epn { - case "echo": - endpoint = c.Echo() - data, err = chatc.BuildEchoPayload(*chatEchoBodyFlag) - } case "feed": c := feedc.NewClient(scheme, host, doer, enc, dec, restore) switch epn { case "watch": endpoint = c.Watch() data, err = feedc.BuildWatchPayload(*feedWatchBodyFlag) + case "snapshot": + endpoint = c.Snapshot() + data, err = feedc.BuildSnapshotPayload(*feedSnapshotBodyFlag) } case "mixed": c := mixedc.NewClient(scheme, host, doer, enc, dec, restore) @@ -254,7 +235,7 @@ func calcAddUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc add --body '{\n \"a\": 281524111196350841,\n \"b\": 4865202500627059760,\n \"id\": \"Perspiciatis sed soluta distinctio facere voluptas et.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc add --body '{\n \"a\": 5718665059814127631,\n \"b\": 5455021967244844938,\n \"id\": \"Assumenda molestias optio.\"\n }'") } func calcPingUsage() { @@ -288,63 +269,54 @@ func calcLogUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc log --body '{\n \"id\": \"Suscipit saepe tempore fuga recusandae amet blanditiis.\",\n \"message\": \"Est quisquam molestiae.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc log --body '{\n \"id\": \"Quae consectetur.\",\n \"message\": \"Quo excepturi.\"\n }'") } -// chatUsage displays the usage of the chat command and its subcommands. -func chatUsage() { - fmt.Fprintln(os.Stderr, `Service is the Chat service interface.`) - fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] chat COMMAND [flags]\n\n", os.Args[0]) +// feedUsage displays the usage of the feed command and its subcommands. +func feedUsage() { + fmt.Fprintln(os.Stderr, `Service is the Feed service interface.`) + fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] feed COMMAND [flags]\n\n", os.Args[0]) fmt.Fprintln(os.Stderr, "COMMAND:") - fmt.Fprintln(os.Stderr, ` echo: Echo implements echo.`) + fmt.Fprintln(os.Stderr, ` watch: Watch implements watch.`) + fmt.Fprintln(os.Stderr, ` snapshot: Snapshot implements snapshot.`) fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Additional help:") - fmt.Fprintf(os.Stderr, " %s chat COMMAND --help\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s feed COMMAND --help\n", os.Args[0]) } -func chatEchoUsage() { +func feedWatchUsage() { // Header with flags - fmt.Fprintf(os.Stderr, "%s [flags] chat echo", os.Args[0]) + fmt.Fprintf(os.Stderr, "%s [flags] feed watch", os.Args[0]) fmt.Fprint(os.Stderr, " -body JSON") fmt.Fprintln(os.Stderr) // Description fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, `Echo implements echo.`) + fmt.Fprintln(os.Stderr, `Watch implements watch.`) // Flags list fmt.Fprintln(os.Stderr, ` -body JSON: `) fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "chat echo --body '{\n \"id\": \"Nemo consequuntur est odio.\",\n \"msg\": \"Accusantium mollitia id sapiente ratione.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "feed watch --body '{\n \"last_event_id\": \"Aliquam eius.\",\n \"request_id\": \"Fugit laborum dignissimos dolore.\"\n }'") } -// feedUsage displays the usage of the feed command and its subcommands. -func feedUsage() { - fmt.Fprintln(os.Stderr, `Service is the Feed service interface.`) - fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] feed COMMAND [flags]\n\n", os.Args[0]) - fmt.Fprintln(os.Stderr, "COMMAND:") - fmt.Fprintln(os.Stderr, ` watch: Watch implements watch.`) - fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, "Additional help:") - fmt.Fprintf(os.Stderr, " %s feed COMMAND --help\n", os.Args[0]) -} -func feedWatchUsage() { +func feedSnapshotUsage() { // Header with flags - fmt.Fprintf(os.Stderr, "%s [flags] feed watch", os.Args[0]) + fmt.Fprintf(os.Stderr, "%s [flags] feed snapshot", os.Args[0]) fmt.Fprint(os.Stderr, " -body JSON") fmt.Fprintln(os.Stderr) // Description fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, `Watch implements watch.`) + fmt.Fprintln(os.Stderr, `Snapshot implements snapshot.`) // Flags list fmt.Fprintln(os.Stderr, ` -body JSON: `) fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "feed watch --body '{\n \"last_event_id\": \"Iure dolor.\",\n \"request_id\": \"Aut voluptas.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "feed snapshot --body '{\n \"request_id\": \"Sed assumenda enim quod.\"\n }'") } // mixedUsage displays the usage of the mixed command and its subcommands. @@ -372,5 +344,5 @@ func mixedLookupUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "mixed lookup --body '{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "mixed lookup --body '{\n \"id\": \"Et rerum porro qui explicabo ut.\",\n \"key\": \"Earum amet voluptatum ad soluta.\"\n }'") } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden index 084bb0c210..265911ad57 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden @@ -10,7 +10,8 @@ package client import ( "encoding/json" "fmt" - feed "kitchensink/feed" + + feed "generated.local/gen/feed" ) // BuildWatchPayload builds the payload for the Feed watch endpoint from CLI @@ -21,7 +22,7 @@ func BuildWatchPayload(feedWatchBody string) (*feed.WatchPayload, error) { { err = json.Unmarshal([]byte(feedWatchBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"last_event_id\": \"Iure dolor.\",\n \"request_id\": \"Aut voluptas.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"last_event_id\": \"Aliquam eius.\",\n \"request_id\": \"Fugit laborum dignissimos dolore.\"\n }'") } } v := &feed.WatchPayload{ @@ -31,3 +32,21 @@ func BuildWatchPayload(feedWatchBody string) (*feed.WatchPayload, error) { return v, nil } + +// BuildSnapshotPayload builds the payload for the Feed snapshot endpoint from +// CLI flags. +func BuildSnapshotPayload(feedSnapshotBody string) (*feed.SnapshotPayload, error) { + var err error + var body SnapshotRequestBody + { + err = json.Unmarshal([]byte(feedSnapshotBody), &body) + if err != nil { + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"request_id\": \"Sed assumenda enim quod.\"\n }'") + } + } + v := &feed.SnapshotPayload{ + RequestID: body.RequestID, + } + + return v, nil +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden index 9f9bfb0ee3..8367680edd 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden @@ -8,9 +8,9 @@ package client import ( - "bufio" "bytes" "context" + "errors" "fmt" "io" "net/http" @@ -37,12 +37,12 @@ type Client struct { decoder func(*http.Response) goahttp.Decoder } -// bufferPool is a pool of bytes.Buffers for encoding requests. +// bufferPool reuses byte buffers while requests are encoded. var bufferPool = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } -// NewClient instantiates HTTP clients for all the Feed service servers. +// NewClient creates HTTP clients for all the Feed service servers. func NewClient( scheme string, host string, @@ -51,7 +51,6 @@ func NewClient( dec func(*http.Response) goahttp.Decoder, restoreBody bool, ) *Client { - return &Client{ Doer: doer, WatchDoer: doer, @@ -84,24 +83,47 @@ func (c *Client) Watch() goa.Endpoint { } if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() + body, readErr := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Feed", "watch", err) + } return nil, goahttp.ErrInvalidResponse("Feed", "watch", resp.StatusCode, string(body)) } contentType := resp.Header.Get("Content-Type") if contentType != "" && !strings.HasPrefix(contentType, "text/event-stream") { - resp.Body.Close() - return nil, fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + contentTypeErr := fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + if err := resp.Body.Close(); err != nil { + return nil, errors.Join(contentTypeErr, goahttp.ErrDecodingError("Feed", "watch", err)) + } + return nil, contentTypeErr } // Create the SSE client stream - stream := &WatchClientStream{ - resp: resp, - reader: bufio.NewReader(resp.Body), - decoder: c.decoder, - } + return NewWatchStream(resp, c.decoder), nil + } +} - return stream, nil +// Snapshot returns an endpoint that makes JSON-RPC requests to the Feed +// service snapshot method. +func (c *Client) Snapshot() goa.Endpoint { + var ( + encodeRequest = EncodeSnapshotRequest(c.encoder) + decodeResponse = DecodeSnapshotResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildSnapshotRequest(ctx, v) + if err != nil { + return nil, err + } + if err := encodeRequest(req, v); err != nil { + return nil, err + } + resp, err := c.Doer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("Feed", "snapshot", err) + } + return decodeResponse(resp) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden index 8c82460695..39df2f857d 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden @@ -10,11 +10,12 @@ package client import ( "bytes" "context" + "errors" "io" - feed "kitchensink/feed" "net/http" "net/url" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" ) @@ -59,50 +60,98 @@ func EncodeWatchRequest(encoder func(*http.Request) goahttp.Encoder) func(*http. } } -// DecodeWatchResponse returns a decoder for responses returned by the Feed -// service watch JSON-RPC method. restoreBody controls whether the response +// BuildSnapshotRequest instantiates a HTTP request object with method and path +// set to call the "Feed" service "snapshot" endpoint +func (c *Client) BuildSnapshotRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: SnapshotFeedPath()} + req, err := http.NewRequest("POST", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("Feed", "snapshot", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// EncodeSnapshotRequest returns an encoder for requests sent to the Feed +// snapshot server. +func EncodeSnapshotRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*feed.SnapshotPayload) + if !ok { + return goahttp.ErrInvalidType("Feed", "snapshot", "*feed.SnapshotPayload", v) + } + b := NewSnapshotRequestBody(p) + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "snapshot", + Params: b, + } + if p.RequestID != "" { + body.ID = p.RequestID + } + // If ID is empty, this is a notification - no ID field + if err := encoder(req).Encode(&body); err != nil { + return goahttp.ErrEncodingError("Feed", "snapshot", err) + } + return nil + } +} + +// DecodeSnapshotResponse returns a decoder for responses returned by the Feed +// service snapshot JSON-RPC method. restoreBody controls whether the response // body should be restored after having been read. -func DecodeWatchResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { +func DecodeSnapshotResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Feed", "snapshot", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Feed", "snapshot", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Feed", "watch", resp.StatusCode, string(body)) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Feed", "snapshot", err) + } + return nil, goahttp.ErrInvalidResponse("Feed", "snapshot", resp.StatusCode, string(body)) } var jresp jsonrpc.RawResponse if err := decoder(resp).Decode(&jresp); err != nil { - return nil, goahttp.ErrDecodingError("Feed", "watch", err) + return nil, goahttp.ErrDecodingError("Feed", "snapshot", err) } if jresp.Error != nil { switch jresp.Error.Code { default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Feed", "watch", resp.StatusCode, string(body)) + return nil, goahttp.ErrInvalidResponse("Feed", "snapshot", resp.StatusCode, string(jresp.Error.Data)) } } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) var ( - body WatchResponseBody + body string err error ) err = decoder(resp).Decode(&body) if err != nil { - return nil, goahttp.ErrDecodingError("Feed", "watch", err) + return nil, goahttp.ErrDecodingError("Feed", "snapshot", err) } - res := NewWatchResultOK(&body) - return res, nil + return body, nil } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/paths.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/paths.go.golden index 1330b366e8..6bb9493df4 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/paths.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/paths.go.golden @@ -11,3 +11,8 @@ package client func WatchFeedPath() string { return "/feed" } + +// SnapshotFeedPath returns the URL path to the Feed service snapshot HTTP endpoint. +func SnapshotFeedPath() string { + return "/feed" +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden index 8bdbe76ea3..46d1928c96 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden @@ -12,29 +12,73 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" - feed "kitchensink/feed" "net/http" "strings" "sync" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" ) -// WatchClientStream implements the feed.WatchClientStream interface using -// Server-Sent Events. -type WatchClientStream struct { - resp *http.Response // HTTP response object - reader *bufio.Reader // Buffered reader for SSE parsing - decoder func(*http.Response) goahttp.Decoder // User-provided decoder - closed bool // Whether the stream has been closed - lock sync.Mutex // Mutex to protect state +type ( + // WatchClientStream reads results sent as server-sent events. + WatchClientStream interface { + Recv() (*feed.WatchResult, error) + RecvWithContext(context.Context) (*feed.WatchResult, error) + Close() error + } + + // WatchStreamImpl reads and decodes events for watch. + WatchStreamImpl struct { + // resp is the open server response. + resp *http.Response + // reader reads one line at a time from resp. + reader *bufio.Reader + // decoder converts each result into its service type. + decoder func(*http.Response) goahttp.Decoder + // closed records whether Close was called or the response ended. + closed bool + // closeOnce ensures the response body is closed only once. + closeOnce sync.Once + // closeErr stores the response body close error. + closeErr error + // lock prevents two calls from reading or closing the response at once. + lock sync.Mutex + } +) + +// NewWatchStream creates a stream that reads server-sent events from resp. +func NewWatchStream(resp *http.Response, decoder func(*http.Response) goahttp.Decoder) WatchClientStream { + return &WatchStreamImpl{ + resp: resp, + reader: bufio.NewReader(resp.Body), + decoder: decoder, + } } -// parseSSEEvent parses a single SSE event from the stream -func (s *WatchClientStream) parseSSEEvent() (eventType string, data []byte, err error) { +// parseSSEEvent reads one complete event from the response. Ending ctx closes +// the response body so a blocked read returns. +func (s *WatchStreamImpl) parseSSEEvent(ctx context.Context) (eventType string, data []byte, err error) { + closeResult := make(chan struct{}, 1) + stopClose := context.AfterFunc(ctx, func() { + s.closeBody() + closeResult <- struct{}{} + }) + defer func() { + if stopClose() { + return + } + <-closeResult + if contextErr := ctx.Err(); contextErr != nil { + eventType = "" + data = nil + err = contextErr + } + }() var event strings.Builder var dataLines []string @@ -42,7 +86,7 @@ func (s *WatchClientStream) parseSSEEvent() (eventType string, data []byte, err line, err := s.reader.ReadString('\n') if err != nil { if err == io.EOF && len(dataLines) > 0 { - // Process final event + // Return the last event even when the response has no final blank line. break } return "", nil, err @@ -52,7 +96,7 @@ func (s *WatchClientStream) parseSSEEvent() (eventType string, data []byte, err line = strings.TrimSuffix(line, "\r") if line == "" { - // Empty line marks end of event + // A blank line ends the current event. if len(dataLines) > 0 { break } @@ -64,7 +108,7 @@ func (s *WatchClientStream) parseSSEEvent() (eventType string, data []byte, err } else if strings.HasPrefix(line, "data:") { dataLines = append(dataLines, strings.TrimSpace(line[5:])) } - // Ignore other fields like id:, retry: + // This client does not use the id and retry fields. } if len(dataLines) > 0 { @@ -75,7 +119,13 @@ func (s *WatchClientStream) parseSSEEvent() (eventType string, data []byte, err } // Recv reads instances of "WatchResult" from the stream. -func (s *WatchClientStream) Recv(ctx context.Context) (*feed.WatchResult, error) { +func (s *WatchStreamImpl) Recv() (*feed.WatchResult, error) { + return s.RecvWithContext(context.Background()) +} + +// RecvWithContext reads instances of "WatchResult" from the stream with +// context. +func (s *WatchStreamImpl) RecvWithContext(ctx context.Context) (*feed.WatchResult, error) { s.lock.Lock() defer s.lock.Unlock() @@ -86,98 +136,93 @@ func (s *WatchClientStream) Recv(ctx context.Context) (*feed.WatchResult, error) } for { - eventType, data, err := s.parseSSEEvent() + eventType, data, err := s.parseSSEEvent(ctx) if err != nil { - s.closed = true - return zero, err + return zero, s.endStream(err) } switch eventType { case "notification": - // Parse JSON-RPC notification + // Read the streamed service result from the notification parameters. var notification struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` Params json.RawMessage `json:"params"` } if err := json.Unmarshal(data, ¬ification); err != nil { - return zero, fmt.Errorf("failed to parse notification: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse notification: %w", err)) } - // Validate notification if notification.JSONRPC != "2.0" { - return zero, fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC) + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC)) } if notification.Method != "watch" { - // Skip notifications for other methods - continue + return zero, s.endStream(fmt.Errorf("received notification for JSON-RPC method %q", notification.Method)) } - // Decode the result from params result, err := s.decodeResult(notification.Params) if err != nil { - return zero, fmt.Errorf("failed to decode result: %w", err) + return zero, s.endStream(fmt.Errorf("failed to decode result: %w", err)) } return result, nil case "response": - // Final response - parse and return + // A successful response completes the stream. Stream values arrive in + // the notifications handled above. var response jsonrpc.Response if err := json.Unmarshal(data, &response); err != nil { - return zero, fmt.Errorf("failed to parse response: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse response: %w", err)) } if response.Error != nil { - return zero, response.Error - } - // Decode the final result - if response.Result == nil { - return zero, fmt.Errorf("missing result in response") - } - // Convert response.Result to json.RawMessage - resultBytes, err := json.Marshal(response.Result) - if err != nil { - return zero, fmt.Errorf("failed to marshal result: %w", err) + return zero, s.endStream(response.Error) } - result, err := s.decodeResult(json.RawMessage(resultBytes)) - if err != nil { - return zero, fmt.Errorf("failed to decode final result: %w", err) - } - - // Mark stream as closed after final response - s.closed = true - return result, nil + return zero, s.endStream(io.EOF) case "error": - // Error response + // A JSON-RPC error completes the stream. var response jsonrpc.Response if err := json.Unmarshal(data, &response); err != nil { - return zero, fmt.Errorf("failed to parse error response: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse error response: %w", err)) } - - s.closed = true if response.Error != nil { - return zero, response.Error + return zero, s.endStream(response.Error) } - return zero, fmt.Errorf("unexpected error response") + return zero, s.endStream(fmt.Errorf("JSON-RPC error event did not contain an error")) default: - // Ignore unknown event types - continue + return zero, s.endStream(fmt.Errorf("unsupported server-sent event type %q", eventType)) } } } -// decodeResult decodes JSON-RPC result data using the user-provided decoder -func (s *WatchClientStream) decodeResult(data json.RawMessage) (*feed.WatchResult, error) { - // Create minimal HTTP response with raw JSON data for user's decoder +// closeBody closes the HTTP response body once and returns its close error. +func (s *WatchStreamImpl) closeBody() error { + s.closeOnce.Do(func() { + s.closeErr = s.resp.Body.Close() + }) + return s.closeErr +} + +// endStream marks the stream closed and preserves both the receive error and +// any error returned while closing the HTTP response body. +func (s *WatchStreamImpl) endStream(err error) error { + s.closed = true + if closeErr := s.closeBody(); closeErr != nil { + return errors.Join(err, closeErr) + } + return err +} + +// decodeResult passes one successful stream item to the decoder configured by NewClient. +func (s *WatchStreamImpl) decodeResult(data json.RawMessage) (*feed.WatchResult, error) { + // Give the configured decoder the successful result bytes as an HTTP response body. resp := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(data)), } - // Use the user-provided decoder to decode the result decoder := s.decoder(resp) var result *feed.WatchResult if err := decoder.Decode(&result); err != nil { @@ -188,15 +233,13 @@ func (s *WatchClientStream) decodeResult(data json.RawMessage) (*feed.WatchResul } // Close closes the stream. -func (s *WatchClientStream) Close() error { +func (s *WatchStreamImpl) Close() error { s.lock.Lock() defer s.lock.Unlock() if !s.closed { s.closed = true - if s.resp != nil && s.resp.Body != nil { - return s.resp.Body.Close() - } + return s.closeBody() } return nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden index 56e7c1ac65..6e24c5971f 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden @@ -8,7 +8,7 @@ package client import ( - feed "kitchensink/feed" + feed "generated.local/gen/feed" ) // WatchRequestBody is the type of the "Feed" service "watch" endpoint HTTP @@ -20,6 +20,13 @@ type WatchRequestBody struct { LastEventID *string `form:"last_event_id,omitempty" json:"last_event_id,omitempty" xml:"last_event_id,omitempty"` } +// SnapshotRequestBody is the type of the "Feed" service "snapshot" endpoint +// HTTP request body. +type SnapshotRequestBody struct { + // Request ID + RequestID string `form:"request_id,omitempty" json:"request_id,omitempty" xml:"request_id,omitempty"` +} + // WatchResponseBody is the type of the "Feed" service "watch" endpoint HTTP // response body. type WatchResponseBody struct { @@ -39,6 +46,15 @@ func NewWatchRequestBody(p *feed.WatchPayload) *WatchRequestBody { return body } +// NewSnapshotRequestBody builds the HTTP request body from the payload of the +// "snapshot" endpoint of the "Feed" service. +func NewSnapshotRequestBody(p *feed.SnapshotPayload) *SnapshotRequestBody { + body := &SnapshotRequestBody{ + RequestID: p.RequestID, + } + return body +} + // NewWatchResultOK builds a "Feed" service "watch" endpoint result from a HTTP // "OK" response. func NewWatchResultOK(body *WatchResponseBody) *feed.WatchResult { diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden index 460413bd0a..b33add53be 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden @@ -11,9 +11,9 @@ import ( "bytes" "errors" "io" - feed "kitchensink/feed" "net/http" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" @@ -49,3 +49,34 @@ func DecodeWatchRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.D return payload, nil } } + +// DecodeSnapshotRequest returns a decoder for requests sent to the Feed +// snapshot endpoint. +func DecodeSnapshotRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*feed.SnapshotPayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*feed.SnapshotPayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *feed.SnapshotPayload + var ( + body SnapshotRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateSnapshotRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewSnapshotPayload(&body) + + return payload, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/paths.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/paths.go.golden index 8df7009ac8..e9ccf29038 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/paths.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/paths.go.golden @@ -11,3 +11,8 @@ package server func WatchFeedPath() string { return "/feed" } + +// SnapshotFeedPath returns the URL path to the Feed service snapshot HTTP endpoint. +func SnapshotFeedPath() string { + return "/feed" +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden index fa9536278a..ba3fdd2712 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden @@ -8,11 +8,17 @@ package server import ( + "bufio" "context" + "errors" "fmt" - feed "kitchensink/feed" + "io" + "mime" "net/http" + "strconv" + "strings" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" @@ -26,6 +32,8 @@ type Server struct { // Watch is the handler for the watch method. Watch func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error + // Snapshot is the handler for the snapshot method. + Snapshot func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error decoder func(*http.Request) goahttp.Decoder encoder func(context.Context, http.ResponseWriter) goahttp.Encoder @@ -44,15 +52,16 @@ func New( s := &Server{ Methods: []string{ "watch", + "snapshot", }, Watch: NewWatchHandler(endpoints.Watch, mux, decoder, encoder, errhandler), + Snapshot: NewSnapshotHandler(endpoints.Snapshot, mux, decoder, encoder, errhandler), decoder: decoder, encoder: encoder, errhandler: errhandler, } - // Default HTTP handler per transport kind - // SSE-only services route via handleSSE - s.Handler = http.HandlerFunc(s.handleSSE) + // Install the request handler required by this service's methods. + s.Handler = http.HandlerFunc(s.ServeHTTP) return s } @@ -67,56 +76,295 @@ func (s *Server) Use(m func(http.Handler) http.Handler) { // MethodNames returns the methods served. func (s *Server) MethodNames() []string { return feed.MethodNames[:] } -// handleSSE handles JSON-RPC SSE requests by dispatching to the appropriate method. -func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() +// ServeHTTP decodes one request and uses the response type designed for its method. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + acceptJSON := false + acceptSSE := false + acceptValues := r.Header.Values("Accept") + if len(acceptValues) == 0 || len(acceptValues) == 1 && strings.TrimSpace(acceptValues[0]) == "" { + acceptJSON = true + acceptSSE = true + } else { + for _, header := range acceptValues { + for _, value := range strings.Split(header, ",") { + mediaType, params, err := mime.ParseMediaType(value) + if err != nil { + continue + } + quality := 1.0 + if value, ok := params["q"]; ok { + quality, err = strconv.ParseFloat(value, 64) + if err != nil { + continue + } + } + if quality <= 0 { + continue + } + switch mediaType { + case "*/*": + acceptJSON = true + acceptSSE = true + case "application/json", "application/*": + acceptJSON = true + case "text/event-stream", "text/*": + acceptSSE = true + } + } + } + } + + originalBody := r.Body + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + } + r.Body = io.NopCloser(bufReader) - // Read the JSON-RPC request + // Request arrays always use ordinary JSON-RPC responses. Streaming methods + // in an array receive one method error and are not called. + if len(peek) > 0 && peek[0] == '[' { + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + if !acceptJSON { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.handleBatch(w, r) + return + } + + // Decode the request once so the generated method switch below can choose + // both the handler and its response type. var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // Emit JSON-RPC parse error as SSE event - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil) + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + switch { + case acceptJSON: + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) + if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) + } + case acceptSSE: + stream := &sseServerStream{w: w, encoder: s.encoder} + if sendErr := stream.sendError(r.Context(), nil, jsonrpc.ParseError, "Parse error", nil); sendErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("write parse error event: %w", sendErr)) + } + default: + w.WriteHeader(http.StatusNotAcceptable) + } + return + } + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + + // Invalid and unknown requests do not have a designed response type. Use + // JSON when the client accepts it, then events, or reject the response. + if req.Invalid || req.JSONRPC != "2.0" || req.Method == "" { + switch { + case acceptJSON: + s.processRequest(r.Context(), r, &req, w) + case acceptSSE: + s.processSSERequest(r.Context(), r, &req, w) + default: + w.WriteHeader(http.StatusNotAcceptable) + } + return + } + + switch req.Method { + case "watch": + if !acceptSSE { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.processSSERequest(r.Context(), r, &req, w) + case "snapshot": + if !acceptJSON { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.processRequest(r.Context(), r, &req, w) + default: + switch { + case acceptJSON: + s.processRequest(r.Context(), r, &req, w) + case acceptSSE: + s.processSSERequest(r.Context(), r, &req, w) + default: + w.WriteHeader(http.StatusNotAcceptable) + } + } +} + +// handleBatch handles an array of JSON-RPC values and writes the required responses. +func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { + var reqs []jsonrpc.RawRequest + if err := s.decoder(r).Decode(&reqs); err != nil { + // An array that cannot be decoded receives the JSON-RPC parse error. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) + if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) + } return } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) + } + return + } + + // Write every response into one JSON array. + w.Header().Set("Content-Type", "application/json") + writer := &batchWriter{Writer: w} - // Validate JSON-RPC request - if req.JSONRPC != "2.0" { - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) + for _, req := range reqs { + // The writer inserts the array separators around each response. + s.processRequest(r.Context(), r, &req, writer) + } + + // Write the closing bracket only when at least one request produced a response. + if writer.written { + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } + } +} + +// processRequest validates the JSON-RPC version and method, then calls the matching handler. +func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { + if req.Invalid || req.JSONRPC != "2.0" { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) return } if req.Method == "" { - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Missing method field", nil) return } - // Find the appropriate handler based on method name - var handler func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error switch req.Method { case "watch": - handler = s.Watch + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method is not available in a batch request", nil) + } + case "snapshot": + if err := s.Snapshot(ctx, r, req, w); err != nil { + s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "snapshot", err)) + } default: - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil) + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } + } +} + +// batchWriter inserts JSON array separators around responses from one request +// array. +type batchWriter struct { + io.Writer + header http.Header + statusCode int + written bool +} + +func (rb *batchWriter) Header() http.Header { + if rb.header == nil { + rb.header = make(http.Header) + } + return rb.header +} + +func (rb *batchWriter) WriteHeader(statusCode int) { + if rb.written { return } + rb.statusCode = statusCode +} - // Call the handler for the specific method - if err := handler(ctx, r, &req, w); err != nil { - s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", req.Method, err)) +func (rb *batchWriter) Write(data []byte) (int, error) { + separator := byte(',') + if !rb.written { + separator = '[' + } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err + } + rb.written = true + return rb.Writer.Write(data) +} + +// processSSERequest validates and runs one server-sent-event request. +func (s *Server) processSSERequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { + + // Reject requests that do not use JSON-RPC 2.0. + if req.Invalid || req.JSONRPC != "2.0" { + stream := &sseServerStream{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } return } - // For notifications (requests without ID) that don't stream, return 204 No Content + if req.Method == "" { + stream := &sseServerStream{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } + return + } + + // Find the function for the requested method. + var handler func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error switch req.Method { + case "watch": + handler = s.Watch + default: + if !req.HasID { + return + } + stream := &sseServerStream{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write method not found event: %w", err)) + } + return + } + + // Call the requested method. + if err := handler(ctx, r, req, w); err != nil { + s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", req.Method, err)) } -} // Mount configures the mux to serve the JSON-RPC Feed service methods. +} + +// Mount configures the mux to serve the JSON-RPC Feed service methods. func Mount(mux goahttp.Muxer, h *Server) { - // SSE only: mount SSE handler - mux.Handle("POST", "/feed", h.handleSSE) + // ServeHTTP chooses ordinary JSON-RPC handling or server-sent events. + mux.Handle("POST", "/feed", h.ServeHTTP) } // Mount configures the mux to serve the JSON-RPC Feed service methods. @@ -136,21 +384,19 @@ func NewWatchHandler( return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { ctx = context.WithValue(ctx, goa.MethodKey, "watch") ctx = context.WithValue(ctx, goa.ServiceKey, "Feed") - // Initialize SSE stream early so decode errors can be sent as SSE error events + // Create the stream before decoding so request failures can be sent on the + // same HTTP response. strm := &WatchServerStream{ sseServerStream: sseServerStream{ w: w, - r: r, encoder: encoder, }, - requestID: req.ID, } decodeParams := DecodeWatchRequest(mux, decoder) params, err := decodeParams(r, req) if err != nil { - // Send error via SSE (JSON-RPC error event) to match SSE transport semantics - if req.ID != nil && req.ID != "" { - strm.SendError(ctx, jsonrpc.IDToString(req.ID), err) + if req.HasID { + return strm.sendError(ctx, req.ID, jsonrpc.InvalidParams, err.Error(), nil) } return nil } @@ -160,31 +406,95 @@ func NewWatchHandler( // Set Last-Event-ID header if present if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" { ctx = context.WithValue(ctx, "last-event-id", lastEventID) + params.LastEventID = &lastEventID } v := &feed.WatchEndpointInput{ Stream: strm, Payload: params, } - if _, err := endpoint(ctx, v); err != nil { - // Send the error as a JSON-RPC error event; SendError applies the - // design-driven error code mapping. - if req.ID != nil && req.ID != "" { - return strm.SendError(ctx, jsonrpc.IDToString(req.ID), err) + _, err = endpoint(ctx, v) + if err != nil { + if !req.HasID { + return nil + } + return strm.sendError(ctx, req.ID, jsonrpc.InternalError, err.Error(), nil) + } + if !req.HasID { + return nil + } + + response := map[string]any{ + "jsonrpc": "2.0", + "id": req.ID, + "result": nil, + } + return strm.sendSSEEvent(ctx, "response", response) + } +} + +// NewSnapshotHandler creates a JSON-RPC handler which calls the "Feed" service +// "snapshot" endpoint. +func NewSnapshotHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + decodeParams := DecodeSnapshotRequest(mux, decoder) + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "snapshot") + ctx = context.WithValue(ctx, goa.ServiceKey, "Feed") + params, err := decodeParams(r, req) + if err != nil { + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) + } else { + // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. + errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) + } + return nil + } + if req.ID != nil { + params.RequestID = jsonrpc.IDToString(req.ID) + } + res, err := endpoint(ctx, params) + if err != nil { + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + } else { + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. + errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil } + if !req.HasID { + // A notification has no ID field and receives no response. + return nil + } + + // For methods with results, determine the ID to use for the response + var id any + // No ID field in result - use request ID + id = req.ID + + // Send response with the result + response := jsonrpc.MakeSuccessResponse(id, res) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) + } return nil } } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func encodeJSONRPCError( ctx context.Context, w http.ResponseWriter, @@ -195,10 +505,8 @@ func encodeJSONRPCError( encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), ) { - if req.ID != nil { - response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) - if err := encoder(ctx, w).Encode(response); err != nil { - errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) - } + response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden index 14f21e3c8e..945730ccbd 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden @@ -8,58 +8,45 @@ package server import ( + "bytes" "context" "fmt" - feed "kitchensink/feed" "net/http" "sync" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" ) -// sseServerStream provides the SSE event encoding machinery shared by all -// JSON-RPC SSE server streams of the service. -type sseServerStream struct { - // once ensures the headers are written once. - once sync.Once - // w is the HTTP response writer used to send the SSE events. - w http.ResponseWriter - // r is the HTTP request. - r *http.Request - // encoder is the response encoder. - encoder func(context.Context, http.ResponseWriter) goahttp.Encoder -} - -// sseEventWriter wraps http.ResponseWriter to format output as SSE events. -type sseEventWriter struct { - w http.ResponseWriter - eventType string - started bool -} +type ( + // sseServerStream writes JSON-RPC messages as server-sent events. + sseServerStream struct { + // once writes the HTTP headers only for the first event. + once sync.Once + // w receives the HTTP headers and event bytes. + w http.ResponseWriter + // encoder turns one JSON-RPC message into bytes. + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder + } -func (s *sseEventWriter) Header() http.Header { return s.w.Header() } -func (s *sseEventWriter) WriteHeader(statusCode int) { s.w.WriteHeader(statusCode) } -func (s *sseEventWriter) Write(data []byte) (int, error) { - if !s.started { - s.started = true - if s.eventType != "" { - fmt.Fprintf(s.w, "event: %s\n", s.eventType) - } - s.w.Write([]byte("data: ")) + // sseEventBuffer stores an encoded event before any HTTP output is written. + sseEventBuffer struct { + bytes.Buffer + header http.Header } - return s.w.Write(data) +) + +// Header returns the headers written while the event is being encoded. +func (b *sseEventBuffer) Header() http.Header { + return b.header } -func (s *sseEventWriter) finish() { - if s.started { - s.w.Write([]byte("\n\n")) - http.NewResponseController(s.w).Flush() - } +// WriteHeader leaves the response status for the real HTTP response writer. +func (b *sseEventBuffer) WriteHeader(int) { } -// initSSEHeaders initializes the SSE response headers +// initSSEHeaders writes the response headers before the first event. func (s *sseServerStream) initSSEHeaders() { s.once.Do(func() { header := s.w.Header() @@ -71,113 +58,66 @@ func (s *sseServerStream) initSSEHeaders() { }) } -// sendSSEEvent sends a single SSE event by creating an encoder that writes to the event writer -func (s *sseServerStream) sendSSEEvent(eventType string, v any) error { - s.initSSEHeaders() - - // Create SSE event writer that wraps the response writer - ew := &sseEventWriter{w: s.w, eventType: eventType} - - // Create encoder with the event writer and encode the value - err := s.encoder(context.Background(), ew).Encode(v) - - // Finish the SSE event (adds newlines and flushes) - ew.finish() +// sendSSEEvent encodes one event before starting the response, then writes and +// flushes that complete event. +func (s *sseServerStream) sendSSEEvent(ctx context.Context, eventType string, value any) error { + event := &sseEventBuffer{header: make(http.Header)} + if err := s.encoder(ctx, event).Encode(value); err != nil { + return err + } - return err + s.initSSEHeaders() + if _, err := fmt.Fprintf(s.w, "event: %s\n", eventType); err != nil { + return fmt.Errorf("write server-sent event name: %w", err) + } + if _, err := s.w.Write([]byte("data: ")); err != nil { + return fmt.Errorf("write server-sent event data label: %w", err) + } + if _, err := s.w.Write(event.Bytes()); err != nil { + return fmt.Errorf("write server-sent event data: %w", err) + } + if _, err := s.w.Write([]byte("\n\n")); err != nil { + return fmt.Errorf("finish server-sent event: %w", err) + } + if err := http.NewResponseController(s.w).Flush(); err != nil { + return fmt.Errorf("flush server-sent event: %w", err) + } + return nil } -// sendError sends a JSON-RPC error response to the SSE stream +// sendError writes one JSON-RPC error as a server-sent event. func (s *sseServerStream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.sendSSEEvent("error", response) + return s.sendSSEEvent(ctx, "error", response) } // WatchServerStream implements the feed.WatchServerStream interface using // Server-Sent Events. type WatchServerStream struct { - // sseServerStream provides the shared SSE event encoding machinery + // sseServerStream writes JSON-RPC messages as server-sent events. sseServerStream - // requestID is the JSON-RPC request ID for sending final response - requestID any - // closed indicates if the stream has been closed via SendAndClose - closed bool - // mu protects the closed flag - mu sync.Mutex } -// Send sends a JSON-RPC notification to the client. -// Notifications do not expect a response from the client. -func (s *WatchServerStream) Send(ctx context.Context, event feed.WatchEvent) error { - // Check if stream is closed - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream closed") - } - s.mu.Unlock() +// Send streams instances of "WatchResult". +func (s *WatchServerStream) Send(event *feed.WatchResult) error { + return s.SendWithContext(context.Background(), event) +} - // Type assert to the specific result type - result, ok := event.(*feed.WatchResult) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - // Convert to response body type for proper JSON encoding +// SendWithContext streams instances of "WatchResult" with context. +func (s *WatchServerStream) SendWithContext(ctx context.Context, event *feed.WatchResult) error { + result := event body := NewWatchResponseBody(result) - // Send as notification (no ID) message := map[string]any{ "jsonrpc": "2.0", "method": "watch", "params": body, } - - return s.sendSSEEvent("notification", message) + return s.sendSSEEvent(ctx, "notification", message) } -// SendAndClose sends a final JSON-RPC response to the client and closes the -// stream. -// The response will include the original request ID unless the result has an -// ID field populated. -// After calling this method, no more events can be sent on this stream. -func (s *WatchServerStream) SendAndClose(ctx context.Context, event feed.WatchEvent) error { - // Check if stream is already closed - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream already closed") - } - s.closed = true - s.mu.Unlock() - - // Type assert to the specific result type - result, ok := event.(*feed.WatchResult) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - - // Determine the ID to use for the response - var id any = s.requestID - // Convert to response body type for proper JSON encoding - body := NewWatchResponseBody(result) - - // Send as response with ID - message := map[string]any{ - "jsonrpc": "2.0", - "id": id, - "result": body, - } - - return s.sendSSEEvent("response", message) -} - -// SendError sends a JSON-RPC error response. -func (s *WatchServerStream) SendError(ctx context.Context, id string, err error) error { - // No custom errors defined - check if it's a validation error, otherwise use - // internal error - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *WatchServerStream) Close() error { + return nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden index 79512461e3..bb39c8aea9 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden @@ -8,8 +8,7 @@ package server import ( - feed "kitchensink/feed" - + feed "generated.local/gen/feed" goa "goa.design/goa/v3/pkg" ) @@ -22,6 +21,13 @@ type WatchRequestBody struct { LastEventID *string `form:"last_event_id,omitempty" json:"last_event_id,omitempty" xml:"last_event_id,omitempty"` } +// SnapshotRequestBody is the type of the "Feed" service "snapshot" endpoint +// HTTP request body. +type SnapshotRequestBody struct { + // Request ID + RequestID *string `form:"request_id,omitempty" json:"request_id,omitempty" xml:"request_id,omitempty"` +} + // WatchResponseBody is the type of the "Feed" service "watch" endpoint HTTP // response body. type WatchResponseBody struct { @@ -51,6 +57,15 @@ func NewWatchPayload(body *WatchRequestBody) *feed.WatchPayload { return v } +// NewSnapshotPayload builds a Feed service snapshot endpoint payload. +func NewSnapshotPayload(body *SnapshotRequestBody) *feed.SnapshotPayload { + v := &feed.SnapshotPayload{ + RequestID: *body.RequestID, + } + + return v +} + // ValidateWatchRequestBody runs the validations defined on WatchRequestBody func ValidateWatchRequestBody(body *WatchRequestBody) (err error) { if body.RequestID == nil { @@ -58,3 +73,12 @@ func ValidateWatchRequestBody(body *WatchRequestBody) (err error) { } return } + +// ValidateSnapshotRequestBody runs the validations defined on +// SnapshotRequestBody +func ValidateSnapshotRequestBody(body *SnapshotRequestBody) (err error) { + if body.RequestID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("request_id", "body")) + } + return +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden index cc9f3238d7..f218a1b52a 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden @@ -10,7 +10,8 @@ package client import ( "encoding/json" "fmt" - mixed "kitchensink/mixed" + + mixed "generated.local/gen/mixed" ) // BuildLookupPayload builds the payload for the Mixed lookup endpoint from CLI @@ -21,7 +22,7 @@ func BuildLookupPayload(mixedLookupBody string) (*mixed.LookupPayload, error) { { err = json.Unmarshal([]byte(mixedLookupBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Et rerum porro qui explicabo ut.\",\n \"key\": \"Earum amet voluptatum ad soluta.\"\n }'") } } v := &mixed.LookupPayload{ diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden index 9f65d3eea8..4b3f482b3e 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden @@ -31,12 +31,12 @@ type Client struct { decoder func(*http.Response) goahttp.Decoder } -// bufferPool is a pool of bytes.Buffers for encoding requests. +// bufferPool reuses byte buffers while requests are encoded. var bufferPool = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } -// NewClient instantiates HTTP clients for all the Mixed service servers. +// NewClient creates HTTP clients for all the Mixed service servers. func NewClient( scheme string, host string, @@ -45,7 +45,6 @@ func NewClient( dec func(*http.Response) goahttp.Decoder, restoreBody bool, ) *Client { - return &Client{ Doer: doer, RestoreResponseBody: restoreBody, diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden index 70091bef0b..3c1632bfa0 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden @@ -10,11 +10,12 @@ package client import ( "bytes" "context" + "errors" "io" - mixed "kitchensink/mixed" "net/http" "net/url" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" ) @@ -63,21 +64,31 @@ func EncodeLookupRequest(encoder func(*http.Request) goahttp.Encoder) func(*http // service lookup JSON-RPC method. restoreBody controls whether the response // body should be restored after having been read. func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Mixed", "lookup", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) + } return nil, goahttp.ErrInvalidResponse("Mixed", "lookup", resp.StatusCode, string(body)) } @@ -89,8 +100,7 @@ func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreB if jresp.Error != nil { switch jresp.Error.Code { default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Mixed", "lookup", resp.StatusCode, string(body)) + return nil, goahttp.ErrInvalidResponse("Mixed", "lookup", resp.StatusCode, string(jresp.Error.Data)) } } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/types.go.golden index c179a3ff8d..5ec06e45fb 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/types.go.golden @@ -8,8 +8,7 @@ package client import ( - mixed "kitchensink/mixed" - + mixed "generated.local/gen/mixed" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/encode_decode.go.golden index 993f585576..cc08c2ce31 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/encode_decode.go.golden @@ -11,9 +11,9 @@ import ( "bytes" "errors" "io" - mixed "kitchensink/mixed" "net/http" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden index 71c205ed09..46e5977fb5 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden @@ -13,9 +13,9 @@ import ( "errors" "fmt" "io" - mixed "kitchensink/mixed" "net/http" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" @@ -53,8 +53,8 @@ func New( encoder: encoder, errhandler: errhandler, } - // Default HTTP handler per transport kind - // Plain HTTP JSON-RPC + // Install the request handler required by this service's methods. + // ServeHTTP handles ordinary JSON-RPC request bodies. s.Handler = http.HandlerFunc(s.ServeHTTP) return s } @@ -73,32 +73,44 @@ func (s *Server) MethodNames() []string { return mixed.MethodNames[:] } // ServeHTTP handles JSON-RPC requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleHTTP(w, r) -} // handleHTTP handles JSON-RPC requests. +} + +// handleHTTP reads one JSON-RPC request object or one array of requests. func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { - // Peek at the first byte to determine request type - bufReader := bufio.NewReader(r.Body) - peek, err := bufReader.Peek(1) - if err != nil && err != io.EOF { - r.Body.Close() - s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", err)) - return + originalBody := r.Body + + // Find the first JSON byte so leading whitespace does not change whether the + // body is decoded as one request or an array. + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } } - // Wrap the buffered reader with the original closer - r.Body = struct { - io.Reader - io.Closer - }{ - Reader: bufReader, - Closer: r.Body, - } - defer func(r *http.Request) { - if err := r.Body.Close(); err != nil { + // The generated handler owns the original body. Decoders receive a wrapper + // whose Close method cannot close it a second time. + r.Body = io.NopCloser(bufReader) + defer func() { + if err := originalBody.Close(); err != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) } - }(r) + }() - // Route to appropriate handler + // A leading '[' starts an array of requests. if len(peek) > 0 && peek[0] == '[' { s.handleBatch(w, r) return @@ -106,11 +118,11 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { s.handleSingle(w, r) } -// handleSingle handles a single JSON-RPC request. +// handleSingle decodes and runs one JSON-RPC request. func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // JSON-RPC parse error with null id and generic message + // A request that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) @@ -120,36 +132,46 @@ func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { s.processRequest(r.Context(), r, &req, w) } -// handleBatch handles a batch of JSON-RPC requests. +// handleBatch handles an array of JSON-RPC values and writes the required responses. func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { var reqs []jsonrpc.RawRequest if err := s.decoder(r).Decode(&reqs); err != nil { - // JSON-RPC parse error for batch with null id and generic message + // An array that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) } return } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) + } + return + } - // Write responses + // Write every response into one JSON array. w.Header().Set("Content-Type", "application/json") writer := &batchWriter{Writer: w} for _, req := range reqs { - // Process the request with batch writer + // The writer inserts the array separators around each response. s.processRequest(r.Context(), r, &req, writer) } - // Close the batch array + // Write the closing bracket only when at least one request produced a response. if writer.written { - writer.Writer.Write([]byte{']'}) + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } } } -// ProcessRequest processes a single JSON-RPC request. +// processRequest validates the JSON-RPC version and method, then calls the matching handler. func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { - if req.JSONRPC != "2.0" { + if req.Invalid || req.JSONRPC != "2.0" { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) return } @@ -165,11 +187,14 @@ func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonr s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "lookup", err)) } default: - s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } } } -// batchWriter is a helper type that implements http.ResponseWriter for writing multiple JSON-RPC responses +// batchWriter inserts JSON array separators around responses from one request +// array. type batchWriter struct { io.Writer header http.Header @@ -192,18 +217,20 @@ func (rb *batchWriter) WriteHeader(statusCode int) { } func (rb *batchWriter) Write(data []byte) (int, error) { + separator := byte(',') if !rb.written { - rb.written = true - rb.Writer.Write([]byte{'['}) - } else { - rb.Writer.Write([]byte{','}) + separator = '[' + } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err } + rb.written = true return rb.Writer.Write(data) } // Mount configures the mux to serve the JSON-RPC Mixed service methods. func Mount(mux goahttp.Muxer, h *Server) { - // HTTP only + // This server handles ordinary JSON-RPC request bodies. mux.Handle("POST", "/mixed/rpc/mixed/rpc", h.ServeHTTP) } @@ -227,15 +254,10 @@ func NewLookupHandler( ctx = context.WithValue(ctx, goa.ServiceKey, "Mixed") params, err := decodeParams(r, req) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the decode error to the configured error handler. errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) } return nil @@ -245,33 +267,18 @@ func NewLookupHandler( } res, err := endpoint(ctx, params) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) } else { - // No ID means notification - just log error + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } return nil } - - // For methods with no result, check if this is a notification + if !req.HasID { + // A notification has no ID field and receives no response. + return nil + } // For methods with results, determine the ID to use for the response var id any @@ -283,13 +290,8 @@ func NewLookupHandler( id = req.ID } - if id == nil || id == "" { - // Notification - no response - return nil - } - // Send response with the result - // Convert result to response body with proper JSON tags + // Build the response body with the fields and JSON names declared by the service. body := NewLookupResponseBody(res.(*mixed.LookupResult)) response := jsonrpc.MakeSuccessResponse(id, body) if err := encoder(ctx, w).Encode(response); err != nil { @@ -299,14 +301,14 @@ func NewLookupHandler( } } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func encodeJSONRPCError( ctx context.Context, w http.ResponseWriter, @@ -317,10 +319,8 @@ func encodeJSONRPCError( encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), ) { - if req.ID != nil { - response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) - if err := encoder(ctx, w).Encode(response); err != nil { - errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) - } + response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/types.go.golden index 33d0f1b2fa..9016c4dca3 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/types.go.golden @@ -8,8 +8,7 @@ package server import ( - mixed "kitchensink/mixed" - + mixed "generated.local/gen/mixed" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/health.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/health.go.golden index 0dc4247c85..789ab0a811 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/health.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/health.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - health "kitchensink/health" + health "generated.local/gen/health" "goa.design/clue/log" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/manifest.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/manifest.golden index df69561d71..2b5dca25b8 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/manifest.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/manifest.golden @@ -1,5 +1,4 @@ calc.go -chat.go cmd/kitchen_sink-cli/http.go cmd/kitchen_sink-cli/jsonrpc.go cmd/kitchen_sink-cli/main.go @@ -34,17 +33,6 @@ gen/jsonrpc/calc/server/encode_decode.go gen/jsonrpc/calc/server/paths.go gen/jsonrpc/calc/server/server.go gen/jsonrpc/calc/server/types.go -gen/jsonrpc/chat/client/cli.go -gen/jsonrpc/chat/client/client.go -gen/jsonrpc/chat/client/encode_decode.go -gen/jsonrpc/chat/client/paths.go -gen/jsonrpc/chat/client/types.go -gen/jsonrpc/chat/client/websocket.go -gen/jsonrpc/chat/server/encode_decode.go -gen/jsonrpc/chat/server/paths.go -gen/jsonrpc/chat/server/server.go -gen/jsonrpc/chat/server/types.go -gen/jsonrpc/chat/server/websocket.go gen/jsonrpc/cli/kitchen_sink/cli.go gen/jsonrpc/feed/client/cli.go gen/jsonrpc/feed/client/client.go diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/mixed.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/mixed.go.golden index a375511629..985bb59267 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/mixed.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/mixed.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - mixed "kitchensink/mixed" + mixed "generated.local/gen/mixed" "goa.design/clue/log" ) diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_decoder.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_decoder.go.golden new file mode 100644 index 0000000000..f399928346 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_decoder.go.golden @@ -0,0 +1,85 @@ +// decodeFetchViewedResult decodes the JSON body selected by the result view +// for the viewed service fetch method. +func decodeFetchViewedResult(decoder func(*http.Response) goahttp.Decoder, resp *http.Response, data json.RawMessage) (*viewed.ViewedGolden, error) { + var representation struct { + View *string `json:"view"` + Body *json.RawMessage `json:"body"` + } + if err := decodeJSONRPCResult(decoder, data, &representation); err != nil { + return nil, err + } + if representation.View == nil { + return nil, goa.MissingFieldError("view", "result") + } + view := *representation.View + switch view { + case "summary": + if representation.Body == nil { + return nil, goa.MissingFieldError("body", "result") + } + resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) + var ( + body FetchResponseBodySummary + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + projected := NewFetchResultSummaryOK(&body) + viewed2 := &viewedviews.ViewedGolden{ + Projected: projected, + View: view, + } + if err := viewedviews.ValidateViewedGolden(viewed2); err != nil { + return nil, err + } + return viewed.NewViewedGolden(viewed2), nil + case "detailed": + if representation.Body == nil { + return nil, goa.MissingFieldError("body", "result") + } + resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) + var ( + body FetchResponseBodyDetailed + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + projected := NewFetchResultDetailedOK(&body) + viewed2 := &viewedviews.ViewedGolden{ + Projected: projected, + View: view, + } + if err := viewedviews.ValidateViewedGolden(viewed2); err != nil { + return nil, err + } + return viewed.NewViewedGolden(viewed2), nil + case "default": + if representation.Body == nil { + return nil, goa.MissingFieldError("body", "result") + } + resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) + var ( + body FetchResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + projected := NewFetchResultDefaultOK(&body) + viewed2 := &viewedviews.ViewedGolden{ + Projected: projected, + View: view, + } + if err := viewedviews.ValidateViewedGolden(viewed2); err != nil { + return nil, err + } + return viewed.NewViewedGolden(viewed2), nil + default: + return nil, goa.InvalidEnumValueError("view", view, []any{"summary", "detailed", "default"}) + } +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_encoder.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_encoder.go.golden new file mode 100644 index 0000000000..a3622a2994 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_encoder.go.golden @@ -0,0 +1,48 @@ +// encodeFetchViewedResult builds the JSON body selected by the result view for +// the viewed service fetch method. +func encodeFetchViewedResult(viewed *viewedviews.ViewedGolden) (any, error) { + if err := viewedviews.ValidateViewedGolden(viewed); err != nil { + return nil, err + } + switch viewed.View { + case "summary": + res := viewed + body := NewFetchResponseBodySummary(res.Projected) + return struct { + View string `json:"view"` + Body any `json:"body"` + }{ + View: "summary", + Body: body, + }, nil + case "detailed": + res := viewed + body := NewFetchResponseBodyDetailed(res.Projected) + return struct { + View string `json:"view"` + Body any `json:"body"` + }{ + View: "detailed", + Body: body, + }, nil + case "default": + res := viewed + body := NewFetchResponseBody(res.Projected) + return struct { + View string `json:"view"` + Body any `json:"body"` + }{ + View: "default", + Body: body, + }, nil + default: + panic("validated viewed result has no JSON-RPC representation") + } +} + +// encodeFetchResult builds and validates the selected result view before +// JSON-RPC encoding. +func encodeFetchResult(result *viewed.ViewedGolden, view string) (any, error) { + viewed := viewed.NewViewedViewedGolden(result, view) + return encodeFetchViewedResult(viewed) +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_client.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_client.go.golden new file mode 100644 index 0000000000..ccec2cef8c --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_client.go.golden @@ -0,0 +1,210 @@ +type ( + // WatchClientStream reads results sent as server-sent events. + WatchClientStream interface { + Recv() (*viewed.ViewedGolden, error) + RecvWithContext(context.Context) (*viewed.ViewedGolden, error) + Close() error + } + + // WatchStreamImpl reads and decodes events for watch. + WatchStreamImpl struct { + // resp is the open server response. + resp *http.Response + // reader reads one line at a time from resp. + reader *bufio.Reader + // decoder converts each result into its service type. + decoder func(*http.Response) goahttp.Decoder + // closed records whether Close was called or the response ended. + closed bool + // closeOnce ensures the response body is closed only once. + closeOnce sync.Once + // closeErr stores the response body close error. + closeErr error + // lock prevents two calls from reading or closing the response at once. + lock sync.Mutex + } +) + +// NewWatchStream creates a stream that reads server-sent events from resp. +func NewWatchStream(resp *http.Response, decoder func(*http.Response) goahttp.Decoder) WatchClientStream { + return &WatchStreamImpl{ + resp: resp, + reader: bufio.NewReader(resp.Body), + decoder: decoder, + } +} + +// parseSSEEvent reads one complete event from the response. Ending ctx closes +// the response body so a blocked read returns. +func (s *WatchStreamImpl) parseSSEEvent(ctx context.Context) (eventType string, data []byte, err error) { + closeResult := make(chan struct{}, 1) + stopClose := context.AfterFunc(ctx, func() { + s.closeBody() + closeResult <- struct{}{} + }) + defer func() { + if stopClose() { + return + } + <-closeResult + if contextErr := ctx.Err(); contextErr != nil { + eventType = "" + data = nil + err = contextErr + } + }() + var event strings.Builder + var dataLines []string + + for { + line, err := s.reader.ReadString('\n') + if err != nil { + if err == io.EOF && len(dataLines) > 0 { + // Return the last event even when the response has no final blank line. + break + } + return "", nil, err + } + + line = strings.TrimSuffix(line, "\n") + line = strings.TrimSuffix(line, "\r") + + if line == "" { + // A blank line ends the current event. + if len(dataLines) > 0 { + break + } + continue + } + + if strings.HasPrefix(line, "event:") { + event.WriteString(strings.TrimSpace(line[6:])) + } else if strings.HasPrefix(line, "data:") { + dataLines = append(dataLines, strings.TrimSpace(line[5:])) + } + // This client does not use the id and retry fields. + } + + if len(dataLines) > 0 { + data = []byte(strings.Join(dataLines, "\n")) + } + + return event.String(), data, nil +} + +// Recv reads instances of "ViewedGolden" from the stream. +func (s *WatchStreamImpl) Recv() (*viewed.ViewedGolden, error) { + return s.RecvWithContext(context.Background()) +} + +// RecvWithContext reads instances of "ViewedGolden" from the stream with +// context. +func (s *WatchStreamImpl) RecvWithContext(ctx context.Context) (*viewed.ViewedGolden, error) { + s.lock.Lock() + defer s.lock.Unlock() + + var zero *viewed.ViewedGolden + + if s.closed { + return zero, io.EOF + } + + for { + eventType, data, err := s.parseSSEEvent(ctx) + if err != nil { + return zero, s.endStream(err) + } + + switch eventType { + case "notification": + // Read the streamed service result from the notification parameters. + var notification struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal(data, ¬ification); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse notification: %w", err)) + } + + if notification.JSONRPC != "2.0" { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC)) + } + + if notification.Method != "watch" { + return zero, s.endStream(fmt.Errorf("received notification for JSON-RPC method %q", notification.Method)) + } + + result, err := s.decodeResult(notification.Params) + if err != nil { + return zero, s.endStream(fmt.Errorf("failed to decode result: %w", err)) + } + return result, nil + + case "response": + // A successful response completes the stream. Stream values arrive in + // the notifications handled above. + var response jsonrpc.Response + if err := json.Unmarshal(data, &response); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse response: %w", err)) + } + + if response.Error != nil { + return zero, s.endStream(response.Error) + } + return zero, s.endStream(io.EOF) + + case "error": + // A JSON-RPC error completes the stream. + var response jsonrpc.Response + if err := json.Unmarshal(data, &response); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse error response: %w", err)) + } + if response.Error != nil { + return zero, s.endStream(response.Error) + } + return zero, s.endStream(fmt.Errorf("JSON-RPC error event did not contain an error")) + + default: + return zero, s.endStream(fmt.Errorf("unsupported server-sent event type %q", eventType)) + } + } +} + +// closeBody closes the HTTP response body once and returns its close error. +func (s *WatchStreamImpl) closeBody() error { + s.closeOnce.Do(func() { + s.closeErr = s.resp.Body.Close() + }) + return s.closeErr +} + +// endStream marks the stream closed and preserves both the receive error and +// any error returned while closing the HTTP response body. +func (s *WatchStreamImpl) endStream(err error) error { + s.closed = true + if closeErr := s.closeBody(); closeErr != nil { + return errors.Join(err, closeErr) + } + return err +} + +// decodeResult passes one successful stream item to the decoder configured by NewClient. +func (s *WatchStreamImpl) decodeResult(data json.RawMessage) (*viewed.ViewedGolden, error) { + // The HTTP 200 status tells the configured decoder that this stream item is + // a successful JSON-RPC result. Streaming results cannot carry HTTP headers or cookies. + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)} + return decodeWatchViewedResult(s.decoder, resp, data) +} + +// Close closes the stream. +func (s *WatchStreamImpl) Close() error { + s.lock.Lock() + defer s.lock.Unlock() + + if !s.closed { + s.closed = true + return s.closeBody() + } + return nil +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_server.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_server.go.golden new file mode 100644 index 0000000000..5fb3f9e06e --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_server.go.golden @@ -0,0 +1,51 @@ +// WatchServerStream implements the viewed.WatchServerStream interface using +// Server-Sent Events. +type WatchServerStream struct { + // sseServerStream writes JSON-RPC messages as server-sent events. + sseServerStream + // view is the result view used to encode later stream values. + view string + // sentView is the result view used by the first event. Later sends must use + // the same view. + sentView string +} + +// SetView selects the result view used by later stream values. +func (s *WatchServerStream) SetView(view string) { + s.view = view +} + +// Send streams instances of "ViewedGolden". +func (s *WatchServerStream) Send(event *viewed.ViewedGolden) error { + return s.SendWithContext(context.Background(), event) +} + +// SendWithContext streams instances of "ViewedGolden" with context. +func (s *WatchServerStream) SendWithContext(ctx context.Context, event *viewed.ViewedGolden) error { + result := event + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + body, err := encodeWatchResult(result, view) + if err != nil { + return err + } + s.sentView = view + + message := map[string]any{ + "jsonrpc": "2.0", + "method": "watch", + "params": body, + } + return s.sendSSEEvent(ctx, "notification", message) +} + +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *WatchServerStream) Close() error { + return nil +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_client.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_client.go.golden new file mode 100644 index 0000000000..a00efefbbe --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_client.go.golden @@ -0,0 +1,46 @@ +// DecodeFetchResponse returns a decoder for responses returned by the viewed +// service fetch JSON-RPC method. restoreBody controls whether the response +// body should be restored after having been read. +func DecodeFetchResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body + if restoreBody { + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("viewed", "fetch", err)) + } + }() + } + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + return nil, goahttp.ErrInvalidResponse("viewed", "fetch", resp.StatusCode, string(body)) + } + + var jresp jsonrpc.RawResponse + if err := decoder(resp).Decode(&jresp); err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + + if jresp.Error != nil { + switch jresp.Error.Code { + default: + return nil, goahttp.ErrInvalidResponse("viewed", "fetch", resp.StatusCode, string(jresp.Error.Data)) + } + } + return decodeFetchViewedResult(decoder, resp, jresp.Result) + } +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_server.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_server.go.golden new file mode 100644 index 0000000000..018bb9e531 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_server.go.golden @@ -0,0 +1,45 @@ +// NewFetchHandler creates a JSON-RPC handler which calls the "viewed" service +// "fetch" endpoint. +func NewFetchHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "fetch") + ctx = context.WithValue(ctx, goa.ServiceKey, "viewed") + res, err := endpoint(ctx, nil) + if err != nil { + if req.HasID { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + } else { + // A notification receives no JSON-RPC response, so pass the service error to the configured error handler. + errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) + } + return nil + } + if !req.HasID { + // A notification has no ID field and receives no response. + return nil + } + + // For methods with results, determine the ID to use for the response + var id any + // No ID field in result - use request ID + id = req.ID + + // Send response with the result + viewedRes := res.(*viewedviews.ViewedGolden) + body, err := encodeFetchViewedResult(viewedRes) + if err != nil { + return err + } + response := jsonrpc.MakeSuccessResponse(id, body) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) + } + return nil + } +} diff --git a/jsonrpc/codegen/testdata/jsonrpc_kitchen_sink_dsls.go b/jsonrpc/codegen/testdata/jsonrpc_kitchen_sink_dsls.go index cdc507fa0a..a41f7cf86c 100644 --- a/jsonrpc/codegen/testdata/jsonrpc_kitchen_sink_dsls.go +++ b/jsonrpc/codegen/testdata/jsonrpc_kitchen_sink_dsls.go @@ -7,10 +7,9 @@ import ( // JSONRPCKitchenSinkDSL exercises the full JSON-RPC generated surface in one // design so golden tests can pin every generator output: a plain JSON-RPC // service (required and optional request IDs, a no-payload method, a method -// with no result, custom errors with JSON-RPC code mappings), a -// WebSocket-only streaming service, an SSE streaming service, a service -// mixing HTTP and JSON-RPC transports on the same methods, and a plain HTTP -// service sharing the design. +// with no result, custom errors with JSON-RPC code mappings), an SSE streaming +// service, a service mixing HTTP and JSON-RPC transports on the same methods, +// and a plain HTTP service sharing the design. var JSONRPCKitchenSinkDSL = func() { API("kitchen-sink", func() { JSONRPC(func() {}) @@ -53,23 +52,6 @@ var JSONRPCKitchenSinkDSL = func() { }) }) - Service("Chat", func() { - JSONRPC(func() { - Path("/ws") - }) - Method("echo", func() { - StreamingPayload(func() { - ID("id", String, "Request ID") - Attribute("msg", String) - }) - StreamingResult(func() { - ID("id", String, "Request ID") - Attribute("echo", String) - }) - JSONRPC(func() {}) - }) - }) - Service("Feed", func() { JSONRPC(func() { POST("/feed") @@ -91,6 +73,14 @@ var JSONRPCKitchenSinkDSL = func() { }) }) }) + Method("snapshot", func() { + Payload(func() { + ID("request_id", String, "Request ID") + Required("request_id") + }) + Result(String) + JSONRPC(func() {}) + }) }) Service("Mixed", func() { diff --git a/jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go b/jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go index 38dbb13ee2..2ac4a31707 100644 --- a/jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go +++ b/jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go @@ -38,7 +38,7 @@ var JSONRPCSSEObjectDSL = func() { Attribute("last_event_id", String, "Last event ID") }) StreamingResult(func() { - ID("id", String, "Event ID") + ID("id", String, "Event ID") Attribute("data", String, "Event data") }) JSONRPC(func() { @@ -49,4 +49,4 @@ var JSONRPCSSEObjectDSL = func() { }) }) }) -} \ No newline at end of file +} diff --git a/jsonrpc/codegen/testdata/jsonrpc_sse_duplicate_dsls.go b/jsonrpc/codegen/testdata/jsonrpc_sse_duplicate_dsls.go index 372c7bc842..6dbd6a2ef8 100644 --- a/jsonrpc/codegen/testdata/jsonrpc_sse_duplicate_dsls.go +++ b/jsonrpc/codegen/testdata/jsonrpc_sse_duplicate_dsls.go @@ -1,30 +1,29 @@ package testdata import ( - . "goa.design/goa/v3/dsl" + . "goa.design/goa/v3/dsl" ) // JSONRPCSSEDuplicateEventDSL defines two JSON-RPC SSE streaming methods that share the // same streaming result type to ensure generated server stream switch does not duplicate cases. var JSONRPCSSEDuplicateEventDSL = func() { - API("jsonrpc-sse-dedupe-test", func() { JSONRPC(func() {}) }) + API("jsonrpc-sse-dedupe-test", func() { JSONRPC(func() {}) }) - var SharedSSEEvent = Type("SharedSSEEvent", func() { - Attribute("data", String) - Required("data") - }) + var SharedSSEEvent = Type("SharedSSEEvent", func() { + Attribute("data", String) + Required("data") + }) - Service("JSONRPCSSEDupeService", func() { - JSONRPC(func() { POST("/stream") }) + Service("JSONRPCSSEDupeService", func() { + JSONRPC(func() { POST("/stream") }) - Method("StreamA", func() { - StreamingResult(SharedSSEEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - Method("StreamB", func() { - StreamingResult(SharedSSEEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - }) + Method("StreamA", func() { + StreamingResult(SharedSSEEvent) + JSONRPC(func() { ServerSentEvents() }) + }) + Method("StreamB", func() { + StreamingResult(SharedSSEEvent) + JSONRPC(func() { ServerSentEvents() }) + }) + }) } - diff --git a/jsonrpc/codegen/testing.go b/jsonrpc/codegen/testing.go index 1da00ba7d0..8cc0f86294 100644 --- a/jsonrpc/codegen/testing.go +++ b/jsonrpc/codegen/testing.go @@ -1,17 +1,69 @@ +// This file builds JSON-RPC code-generation analysis in tests using the same +// generation construction, planning, freezing, and rendering as production. package codegen import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// CreateJSONRPCServices creates a new ServicesData instance for JSON-RPC -// testing. The root is normalized first like the production Generate flow -// does before the generators read the design. -func CreateJSONRPCServices(root *expr.RootExpr) *httpcodegen.ServicesData { - codegen.NormalizeRoot(root) - services := service.NewServicesData(root) - return httpcodegen.NewJSONRPCServicesData(services, &root.API.JSONRPC.HTTPExpr) +// CreateJSONRPCPlan builds and links the same service, HTTP, and JSON-RPC plans +// that production generation uses. +func CreateJSONRPCPlan(root *expr.RootExpr) *Plan { + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + if err != nil { + panic(err) + } + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + if err != nil { + panic(err) + } + var applicationHTTP *httpcodegen.Plan + if len(root.API.HTTP.Services) > 0 { + applicationPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{ + Root: root, + Service: servicePlan, + }) + if err != nil { + panic(err) + } + applicationHTTP = applicationPlans[0] + } + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{ + Root: root, + Service: servicePlan, + }) + if err != nil { + panic(err) + } + plans, err := NewPlans(generation, PlanInput{ + Root: root, + Service: servicePlan, + HTTP: httpPlans[0], + ApplicationHTTP: applicationHTTP, + }) + if err != nil { + panic(err) + } + if err := generation.Freeze(); err != nil { + panic(err) + } + if err := servicePlan.Link(); err != nil { + panic(err) + } + if applicationHTTP != nil { + if err := applicationHTTP.Link(); err != nil { + panic(err) + } + } + if err := httpPlans[0].Link(); err != nil { + panic(err) + } + if err := plans[0].Link(); err != nil { + panic(err) + } + return plans[0] } diff --git a/jsonrpc/codegen/viewed_result.go b/jsonrpc/codegen/viewed_result.go new file mode 100644 index 0000000000..4fb36a6691 --- /dev/null +++ b/jsonrpc/codegen/viewed_result.go @@ -0,0 +1,311 @@ +// This file connects each JSON-RPC result view to the HTTP JSON body and +// service constructor chosen for that endpoint. Unary calls and SSE streams +// use the same generated functions, so clients decode the same JSON shape that +// servers encode. +package codegen + +import ( + "fmt" + "reflect" + "strings" + + "goa.design/goa/v3/codegen" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +type ( + // viewedResultTemplateData contains one method's allowed views, body types, + // and generated function names. + viewedResultTemplateData struct { + ServiceName string + MethodName string + Decode *codegen.NameDeclaration + Encode *codegen.NameDeclaration + StreamEncode *codegen.NameDeclaration + WriteMetadata *codegen.NameDeclaration + BodyDecoder *codegen.NameDeclaration + Variable bool + FixedView string + Branches []*viewBranchTemplateData + ViewedTypeRef string + ViewedVarName string + ViewedPkg string + ViewedValidator string + ServiceResultConstructor string + ServiceViewedConstructor string + ServicePkg string + ViewedValue string + ResultRef string + IsCollection bool + HasResponseMetadata bool + } + + // viewBranchTemplateData contains one view's server body, client body, and + // function that rebuilds the result. + viewBranchTemplateData struct { + View string + ResultAttr string + ServerBody *httpcodegen.JSONRPCBodyData + ClientBody *httpcodegen.JSONRPCBodyData + ResultInit httpcodegen.InitData + Headers []httpcodegen.JSONRPCHeaderData + Cookies []httpcodegen.JSONRPCCookieData + } +) + +// clientViewedResultSections returns the result decoders written to one +// generated service client package. +func clientViewedResultSections(service *servicePlan) []*codegen.SectionTemplate { + var sections []*codegen.SectionTemplate + for _, endpoint := range service.endpoints { + if endpoint.viewed == nil { + continue + } + sections = append(sections, &codegen.SectionTemplate{ + Name: "jsonrpc-viewed-result-decoder", + Source: jsonrpcTemplates.Read(viewedResultDecodeT, singleResponseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP), + Data: viewedResultData(service, endpoint), + FuncMap: map[string]any{ + "viewedResponseData": viewedResponseData, + }, + }) + } + return sections +} + +// serverViewedResultSections returns the result encoders written to one +// generated service server package. +func serverViewedResultSections(service *servicePlan) []*codegen.SectionTemplate { + var sections []*codegen.SectionTemplate + for _, endpoint := range service.endpoints { + if endpoint.viewed == nil { + continue + } + sections = append(sections, &codegen.SectionTemplate{ + Name: "jsonrpc-viewed-result-encoder", + Source: jsonrpcTemplates.Read(viewedResultEncodeT, headerConversionP, viewedResultMetadataP), + Data: viewedResultData(service, endpoint), + FuncMap: map[string]any{ + "headerConversionData": viewedHeaderConversionData, + "printValue": viewedPrintValue, + "goTypeRef": viewedGoTypeRef, + }, + }) + } + return sections +} + +// viewedResultFuncs returns functions that read names already declared in the +// generated client and server packages. +func viewedResultFuncs(service *servicePlan) map[string]any { + return map[string]any{ + "viewedDecodeName": service.viewedDecodeName, + "viewedEncodeName": service.viewedEncodeName, + "viewedStreamEncodeName": service.viewedStreamEncodeName, + "viewedMetadataName": service.viewedMetadataName, + "viewedHasMetadata": service.viewedHasMetadata, + } +} + +// viewedDecodeName returns the generated client decoder name for method. +func (s *servicePlan) viewedDecodeName(method string) string { + return s.viewedHelpers(method).decode.Name() +} + +// viewedEncodeName returns the generated server encoder name for method. +func (s *servicePlan) viewedEncodeName(method string) string { + return s.viewedHelpers(method).encode.Name() +} + +// viewedStreamEncodeName returns the generated server stream encoder name for method. +func (s *servicePlan) viewedStreamEncodeName(method string) string { + return s.viewedHelpers(method).streamEncode.Name() +} + +// viewedMetadataName returns the server function that writes a method's +// successful response headers and cookies. +func (s *servicePlan) viewedMetadataName(method string) string { + return s.viewedHelpers(method).writeMetadata.Name() +} + +// viewedHasMetadata reports whether the unary response for method writes at +// least one mapped HTTP header or cookie. +func (s *servicePlan) viewedHasMetadata(method string) bool { + for _, endpoint := range s.endpoints { + if endpoint.Method.Name != method || endpoint.viewed == nil { + continue + } + for _, branch := range endpoint.viewed.branches { + if len(branch.headers) > 0 || len(branch.cookies) > 0 { + return true + } + } + return false + } + panic("JSON-RPC response metadata requested for unplanned method " + method) +} + +// viewedHelpers returns the function names declared for a method that returns a +// result view. It panics when the generated file asks for an unknown method. +func (s *servicePlan) viewedHelpers(method string) *viewedHelperDeclarations { + declarations := s.helpers[method] + if declarations == nil { + panic("JSON-RPC viewed helper requested for unplanned method " + method) + } + return declarations +} + +// viewedResultData returns the method and function names used to write result +// conversion code. It does not read the design or create new names. +func viewedResultData(service *servicePlan, endpoint *endpointPlan) *viewedResultTemplateData { + representation := endpoint.viewed + viewed := representation.viewedResult + branches := make([]*viewBranchTemplateData, len(representation.branches)) + for index, branch := range representation.branches { + branches[index] = &viewBranchTemplateData{ + View: branch.view, + ResultAttr: branch.resultAttr, + ServerBody: branch.serverBody, + ClientBody: branch.clientBody, + ResultInit: branch.resultInit, + Headers: branch.headers, + Cookies: branch.cookies, + } + } + localScope := codegen.NewNameScope() + localScope.Unique(representation.servicePkg) + localScope.Unique(viewed.ViewsPkg) + + return &viewedResultTemplateData{ + ServiceName: endpoint.ServiceName, + MethodName: endpoint.Method.Name, + Decode: representation.decode, + Encode: representation.encode, + StreamEncode: representation.streamEncode, + WriteMetadata: representation.writeMetadata, + BodyDecoder: service.bodyDecoder, + Variable: representation.variable, + FixedView: representation.fixedView, + Branches: branches, + ViewedTypeRef: viewed.FullRef, + ViewedVarName: viewed.VarName, + ViewedPkg: viewed.ViewsPkg, + ViewedValidator: viewed.Validate.Name(), + ServiceResultConstructor: viewed.ResultInit.Name(), + ServiceViewedConstructor: viewed.Init.Name(), + ServicePkg: representation.servicePkg, + ViewedValue: localScope.Unique("viewed"), + ResultRef: representation.resultRef, + IsCollection: viewed.IsCollection, + HasResponseMetadata: representationHasMetadata(representation), + } +} + +// representationHasMetadata reports whether any allowed view maps a result +// field to an HTTP response header or cookie. +func representationHasMetadata(representation *viewedRepresentation) bool { + for _, branch := range representation.branches { + if len(branch.headers) > 0 || len(branch.cookies) > 0 { + return true + } + } + return false +} + +// serviceNeedsMetadataStrconv reports whether a mapped response header or +// cookie contains a number or boolean that generated code must format as text. +func serviceNeedsMetadataStrconv(service *servicePlan) bool { + for _, endpoint := range service.endpoints { + if endpoint.viewed == nil { + continue + } + for _, branch := range endpoint.viewed.branches { + for _, header := range branch.headers { + if metadataTypeNeedsStrconv(header.TypeName, header.ElemTypeName) { + return true + } + } + for _, cookie := range branch.cookies { + if metadataTypeNeedsStrconv(cookie.TypeName, cookie.ElemTypeName) { + return true + } + } + } + } + return false +} + +// metadataTypeNeedsStrconv reports whether dataType or an array element uses +// strconv when generated code turns it into response text. +func metadataTypeNeedsStrconv(typeName, elementTypeName string) bool { + if typeName == "array" { + return metadataTypeNeedsStrconv(elementTypeName, "") + } + return typeName != "string" && typeName != "bytes" && typeName != "any" +} + +// viewedResponseData gives the response reader one view's body, header, and +// cookie fields together with the service and method names used in errors. +func viewedResponseData(branch *viewBranchTemplateData, serviceName, methodName string) map[string]any { + return map[string]any{ + "Data": map[string]any{ + "ClientBody": branch.ClientBody, + "Headers": branch.Headers, + "Cookies": branch.Cookies, + "MustValidate": len(branch.Headers) > 0 || len(branch.Cookies) > 0, + }, + "ServiceName": serviceName, + "Method": map[string]any{"Name": methodName}, + } +} + +// viewedHeaderConversionData names the value that generated code turns into +// response header text. +func viewedHeaderConversionData(typeName, elementTypeName, varName string, required bool, target string) map[string]any { + return map[string]any{ + "TypeName": typeName, + "ElemTypeName": elementTypeName, + "VarName": varName, + "Required": required, + "Target": target, + } +} + +// viewedPrintValue returns the text used for a designed default header or +// cookie value. Arrays join their element values with a comma and a space. +func viewedPrintValue(typeName, elementTypeName string, value any) string { + if typeName == "array" { + values := reflect.ValueOf(value) + parts := make([]string, values.Len()) + for index := 0; index < values.Len(); index++ { + parts[index] = viewedPrintValue(elementTypeName, "", values.Index(index).Interface()) + } + return strings.Join(parts, ", ") + } + switch typeName { + case "boolean", "int", "int32", "int64", "uint", "uint32", "uint64", "float32", "float64", "string", "bytes", "any": + return fmt.Sprintf("%v", value) + default: + panic("JSON-RPC response metadata has an unsupported default type " + typeName) + } +} + +// viewedGoTypeRef returns the built-in Go type used while converting an +// aliased result field to response text. +func viewedGoTypeRef(typeName, elementTypeName string) string { + if typeName == "array" { + return "[]" + viewedGoTypeRef(elementTypeName, "") + } + switch typeName { + case "boolean": + return "bool" + case "bytes": + return "[]byte" + case "any": + return "any" + case "int", "int32", "int64", "uint", "uint32", "uint64", "float32", "float64", "string": + return typeName + default: + panic("JSON-RPC response metadata has an unsupported type " + typeName) + } +} diff --git a/jsonrpc/codegen/viewed_result_golden_test.go b/jsonrpc/codegen/viewed_result_golden_test.go new file mode 100644 index 0000000000..2e88d70bf5 --- /dev/null +++ b/jsonrpc/codegen/viewed_result_golden_test.go @@ -0,0 +1,146 @@ +// This file checks the generated JSON-RPC code that carries a selected result +// view through unary responses and server-sent events. +package codegen + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" +) + +func TestVariableViewedResultGeneratedSource(t *testing.T) { + _, plan := linkedJSONRPCPlan(t, variableViewedResultGoldenDSL) + require.Len(t, plan.services, 1) + service := plan.services[0] + + clientConversions := clientViewedResultSections(service) + serverConversions := serverViewedResultSections(service) + require.Len(t, clientConversions, 2) + require.Len(t, serverConversions, 2) + clientData := clientConversions[0].Data.(*viewedResultTemplateData) + require.Equal(t, "fetch", clientData.MethodName) + require.Equal(t, "viewed2", clientData.ViewedValue) + require.Equal(t, "fetch", serverConversions[0].Data.(*viewedResultTemplateData).MethodName) + testutil.AssertGo( + t, + "testdata/golden/viewed_result_variable_decoder.go.golden", + codegen.SectionCode(t, clientConversions[0]), + ) + testutil.AssertGo( + t, + "testdata/golden/viewed_result_variable_encoder.go.golden", + codegen.SectionCode(t, serverConversions[0]), + ) + + tests := []struct { + name string + files []*codegen.File + packageName string + fileName string + sectionName string + sectionCount int + golden string + }{ + { + name: "unary client", + files: plan.ClientFiles(), + packageName: "client", + fileName: "encode_decode.go", + sectionName: "jsonrpc-response-decoder", + sectionCount: 1, + golden: "testdata/golden/viewed_result_variable_unary_client.go.golden", + }, + { + name: "unary server", + files: plan.ServerFiles(), + packageName: "server", + fileName: "server.go", + sectionName: "jsonrpc-server-handler-init", + sectionCount: 2, + golden: "testdata/golden/viewed_result_variable_unary_server.go.golden", + }, + { + name: "SSE client", + files: plan.ClientFiles(), + packageName: "client", + fileName: "stream.go", + sectionName: "jsonrpc-sse-client-stream", + sectionCount: 1, + golden: "testdata/golden/viewed_result_variable_sse_client.go.golden", + }, + { + name: "SSE server", + files: plan.ServerFiles(), + packageName: "server", + fileName: "sse.go", + sectionName: "jsonrpc-sse-server-stream", + sectionCount: 1, + golden: "testdata/golden/viewed_result_variable_sse_server.go.golden", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + file := viewedResultGoldenFile(t, test.files, test.packageName, test.fileName) + sections := file.Section(test.sectionName) + require.Len(t, sections, test.sectionCount) + testutil.AssertGo(t, test.golden, codegen.SectionCode(t, sections[0])) + }) + } +} + +// viewedResultGoldenFile returns one generated client or server file for the +// viewed service used by the snapshots. +func viewedResultGoldenFile(t *testing.T, files []*codegen.File, packageName, fileName string) *codegen.File { + t.Helper() + for _, file := range files { + if filepath.Base(file.Path) != fileName { + continue + } + if filepath.Base(filepath.Dir(file.Path)) != packageName { + continue + } + if filepath.Base(filepath.Dir(filepath.Dir(file.Path))) == "viewed" { + return file + } + } + t.Fatalf("generated viewed/%s/%s file not found", packageName, fileName) + return nil +} + +// variableViewedResultGoldenDSL defines one unary method and one server stream +// whose callers choose between the two named views or the generated default. +func variableViewedResultGoldenDSL() { + result := dsl.ResultType("application/vnd.viewed-golden", func() { + dsl.TypeName("ViewedGolden") + dsl.Attribute("id", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("id", "detail") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + dsl.View("detailed", func() { + dsl.Attribute("id") + dsl.Attribute("detail") + }) + }) + dsl.Service("viewed", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + dsl.Method("watch", func() { + dsl.StreamingResult(result) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + }) +} diff --git a/jsonrpc/codegen/viewed_result_runtime_regression_test.go b/jsonrpc/codegen/viewed_result_runtime_regression_test.go new file mode 100644 index 0000000000..1812381961 --- /dev/null +++ b/jsonrpc/codegen/viewed_result_runtime_regression_test.go @@ -0,0 +1,1261 @@ +// This file renders JSON-RPC clients and servers into a temporary Go module. +// The generated tests call each client with an application-supplied decoder +// and send an invalid request to an SSE server, then inspect the response data +// and errors that the generated code gives the application. +package codegen_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" +) + +// TestGeneratedViewedClientDecodersReceiveOKStatus renders a unary call and an +// SSE stream, then runs each generated client. +func TestGeneratedViewedClientDecodersReceiveOKStatus(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultRuntimeTest(t, dir, "unary_status", unaryStatusRuntimeTest) + writeViewedResultRuntimeTest(t, dir, "sse_status", sseStatusRuntimeTest) + runViewedResultRuntimeTests(t, dir, + "./jsonrpc/unary_status/client", + "./jsonrpc/sse_status/client", + ) +} + +// TestGeneratedSSELifecycle renders a result stream and checks every JSON-RPC +// message written when the service sends values and returns. +func TestGeneratedSSELifecycle(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "sse_decode", sseLifecycleRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_decode/server") +} + +// TestGeneratedMappedObjectBodyValidatesRequiredFields renders an explicit +// object response body, decodes both selected views, and checks the required +// field when the generated client receives the selected body. +func TestGeneratedMappedObjectBodyValidatesRequiredFields(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultRuntimeTest(t, dir, "mapped_body", mappedBodyRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/mapped_body/client") +} + +// TestGeneratedViewedUnaryResponseMetadata sends viewed results through a +// generated server and client. It checks a response with a JSON body and a +// response whose result is carried only by an HTTP header and cookie. +func TestGeneratedViewedUnaryResponseMetadata(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "unary_metadata", unaryMetadataRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/unary_metadata/server") +} + +// TestGeneratedServerReturnsRequestBodyFailures makes reading and closing one +// request body fail and checks that the generated server reports both errors. +func TestGeneratedServerReturnsRequestBodyFailures(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "unary_status", requestBodyFailureRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/unary_status/server") +} + +// TestGeneratedSSEDecodeErrorReturnsWriteFailure sends a request that omits a +// required parameter and makes writing the JSON-RPC error event fail. The +// generated server must report that failure once without starting a new +// response. +func TestGeneratedSSEDecodeErrorReturnsWriteFailure(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "sse_decode", sseDecodeErrorRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_decode/server") +} + +// TestGeneratedSSERecvWithContextStopsBlockedRead renders an SSE client and +// checks that canceling a receive closes its response body and ends the stream. +func TestGeneratedSSERecvWithContextStopsBlockedRead(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultRuntimeTest(t, dir, "sse_status", sseCancellationRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_status/client") +} + +// renderViewedResultRuntimeModule writes the generated service and JSON-RPC +// client files used by these tests. It uses this Goa checkout and leaves the +// repository's generated files unchanged. +func renderViewedResultRuntimeModule(t *testing.T) string { + t.Helper() + root := expr.RunDSL(t, viewedResultRuntimeDSL) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{ + Root: root, + Service: servicePlan, + }) + require.NoError(t, err) + jsonPlans, err := jsonrpccodegen.NewPlans(generation, jsonrpccodegen.PlanInput{ + Root: root, + Service: servicePlan, + HTTP: httpPlans[0], + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, jsonPlans[0].Link()) + + files, err := service.Files(servicePlan) + require.NoError(t, err) + files = append(files, jsonPlans[0].ClientFiles()...) + files = append(files, jsonPlans[0].ServerFiles()...) + files = append(files, jsonPlans[0].ClientTypeFiles()...) + files = append(files, jsonPlans[0].ServerTypeFiles()...) + files = append(files, jsonPlans[0].PathFiles()...) + base := t.TempDir() + for _, file := range files { + _, err := file.Render(base) + require.NoError(t, err) + } + + moduleDir := filepath.Join(base, goacodegen.Gendir) + workingDir, err := os.Getwd() + require.NoError(t, err) + repository := filepath.Clean(filepath.Join(workingDir, "..", "..")) + goMod := fmt.Sprintf("module generated.local/gen\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\nreplace goa.design/goa/v3 => %s\n", repository) + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(goMod), 0o600)) + return moduleDir +} + +// writeViewedResultRuntimeTest adds a client test to the temporary module. +func writeViewedResultRuntimeTest(t *testing.T, moduleDir, serviceName, source string) { + t.Helper() + dir := filepath.Join(moduleDir, "jsonrpc", serviceName, "client") + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "viewed_result_runtime_test.go"), []byte(source), 0o600)) +} + +// writeViewedResultServerRuntimeTest adds a server test to the temporary +// module without writing into this repository's generated directories. +func writeViewedResultServerRuntimeTest(t *testing.T, moduleDir, serviceName, source string) { + t.Helper() + dir := filepath.Join(moduleDir, "jsonrpc", serviceName, "server") + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "sse_error_runtime_test.go"), []byte(source), 0o600)) +} + +// runViewedResultRuntimeTests runs only the generated packages named by +// patterns so each failure identifies the client call or server request that +// supplied unexpected data. +func runViewedResultRuntimeTests(t *testing.T, moduleDir string, patterns ...string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + args := append([]string{"test", "-mod=mod"}, patterns...) + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = moduleDir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) +} + +// viewedResultRuntimeDSL defines the JSON-RPC methods rendered into the +// temporary module used by these tests. +func viewedResultRuntimeDSL() { + result := viewedStatusResult() + dsl.Service("Unary Status", func() { + dsl.JSONRPC(func() { + dsl.POST("/unary") + }) + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) + dsl.Service("SSE Status", func() { + dsl.JSONRPC(func() { + dsl.POST("/sse") + }) + dsl.Method("watch", func() { + dsl.StreamingResult(result) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + }) + dsl.Service("SSE Decode", func() { + dsl.JSONRPC(func() { + dsl.POST("/decode") + }) + dsl.Method("watch", func() { + dsl.Payload(func() { + dsl.Attribute("topic", dsl.String) + dsl.Required("topic") + }) + dsl.StreamingResult(func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + }) + dsl.Service("Protocol", func() { + dsl.JSONRPC(func() { + dsl.POST("/protocol") + }) + dsl.Method("ping", func() { + dsl.JSONRPC(func() {}) + }) + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + }) + mapped := dsl.ResultType("application/vnd.mapped-body", func() { + dsl.TypeName("MappedBody") + dsl.Attribute("id", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Attribute("detail", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + dsl.View("detailed", func() { + dsl.Attribute("id") + dsl.Attribute("detail") + }) + }) + dsl.Service("Mapped Body", func() { + dsl.JSONRPC(func() { + dsl.POST("/mapped") + }) + dsl.Method("fetch", func() { + dsl.Result(mapped) + dsl.JSONRPC(func() { + dsl.Response(func() { + dsl.Body("id") + }) + }) + }) + }) + + metadata := dsl.ResultType("application/vnd.unary-metadata", func() { + dsl.TypeName("UnaryMetadata") + dsl.Attribute("value", dsl.String) + dsl.Attribute("etag", dsl.String) + dsl.Attribute("session", dsl.String) + dsl.Required("etag", "session") + dsl.View("summary", func() { + dsl.Attribute("value") + dsl.Attribute("etag") + dsl.Attribute("session") + }) + dsl.View("detailed", func() { + dsl.Attribute("value") + dsl.Attribute("etag") + dsl.Attribute("session") + }) + }) + metadataOnly := dsl.ResultType("application/vnd.unary-metadata-only", func() { + dsl.TypeName("UnaryMetadataOnly") + dsl.Attribute("etag", dsl.String) + dsl.Attribute("session", dsl.String) + dsl.Required("etag", "session") + dsl.View("default", func() { + dsl.Attribute("etag") + dsl.Attribute("session") + }) + }) + dsl.Service("Unary Metadata", func() { + dsl.JSONRPC(func() { + dsl.POST("/metadata") + }) + dsl.Method("fetch", func() { + dsl.Result(metadata) + dsl.JSONRPC(func() { + dsl.Response(func() { + dsl.Body("value") + dsl.Header("etag:X-ETag") + dsl.Cookie("session:SID") + }) + }) + }) + dsl.Method("only", func() { + dsl.Result(metadataOnly) + dsl.JSONRPC(func() { + dsl.Response(func() { + dsl.Body(dsl.Empty) + dsl.Header("etag:X-ETag") + dsl.Cookie("session:SID") + }) + }) + }) + }) +} + +// viewedStatusResult defines two views so each client must decode both the +// selected view name and its corresponding JSON body. +func viewedStatusResult() *expr.ResultTypeExpr { + return dsl.ResultType("application/vnd.decoder-status", func() { + dsl.TypeName("DecoderStatus") + dsl.Attribute("label", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("label") + dsl.View("summary", func() { + dsl.Attribute("label") + }) + dsl.View("detailed", func() { + dsl.Attribute("label") + dsl.Attribute("detail") + }) + }) +} + +const unaryStatusRuntimeTest = `package client + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(request *http.Request) (*http.Response, error) { + return f(request) +} + +type trackedResponseBody struct { + reader io.Reader + closeErr error +} + +func (body *trackedResponseBody) Read(buffer []byte) (int, error) { + return body.reader.Read(buffer) +} + +func (body *trackedResponseBody) Close() error { + return body.closeErr +} + +func TestUnaryViewedDecoderReceivesHTTPStatusOK(t *testing.T) { + statuses := make([]int, 0, 3) + decoder := func(response *http.Response) goahttp.Decoder { + statuses = append(statuses, response.StatusCode) + return goahttp.ResponseDecoder(response) + } + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":{"view":"summary","body":{"label":"ready"}}}` + "`" + `, + )), + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, decoder, false) + _, err := client.Fetch()(context.Background(), nil) + require.NoError(t, err) + require.NotEmpty(t, statuses) + for _, status := range statuses { + require.Equal(t, http.StatusOK, status) + } +} + +func TestUnaryDecoderReturnsResponseCloseFailure(t *testing.T) { + closeErr := errors.New("close failed") + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: &trackedResponseBody{ + reader: strings.NewReader( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":{"view":"summary","body":{"label":"ready"}}}` + "`" + `, + ), + closeErr: closeErr, + }, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + _, err := client.Fetch()(context.Background(), nil) + assertDecodingError(t, err) + require.ErrorIs(t, err, closeErr) +} + +func TestUnaryDecoderReturnsDecodeAndCloseFailures(t *testing.T) { + decodeErr := errors.New("decode failed") + closeErr := errors.New("close failed") + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: &trackedResponseBody{ + reader: strings.NewReader("ignored"), + closeErr: closeErr, + }, + }, nil + }) + decoder := func(*http.Response) goahttp.Decoder { + return goahttp.EncodingFunc(func(any) error { + return decodeErr + }) + } + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, decoder, false) + + _, err := client.Fetch()(context.Background(), nil) + + assertDecodingError(t, err) + require.ErrorIs(t, err, decodeErr) + require.ErrorIs(t, err, closeErr) +} + +func assertDecodingError(t *testing.T, err error) { + t.Helper() + var clientErr *goahttp.ClientError + require.ErrorAs(t, err, &clientErr) + require.Equal(t, "decoding_error", clientErr.Name) +} +` + +const sseStatusRuntimeTest = `package client + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_status" + goahttp "goa.design/goa/v3/http" + "goa.design/goa/v3/jsonrpc" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(request *http.Request) (*http.Response, error) { + return f(request) +} + +type failingResponseBody struct { + reader io.Reader + readErr error + closeErr error + closes int +} + +func (body *failingResponseBody) Read(buffer []byte) (int, error) { + if body.reader != nil { + return body.reader.Read(buffer) + } + return 0, body.readErr +} + +func (body *failingResponseBody) Close() error { + body.closes++ + return body.closeErr +} + +func TestSSEViewedDecoderReceivesHTTPStatusOK(t *testing.T) { + statuses := make([]int, 0, 2) + decoder := func(response *http.Response) goahttp.Decoder { + statuses = append(statuses, response.StatusCode) + return goahttp.ResponseDecoder(response) + } + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "event: notification\ndata: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + "\n\n" + + "event: response\ndata: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":null}` + "`" + ` + "\n\n", + )), + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, decoder, false) + raw, err := client.Watch()(context.Background(), nil) + require.NoError(t, err) + stream := raw.(*WatchStreamImpl) + var serviceStream service.WatchClientStream = stream + _, err = serviceStream.Recv() + require.NoError(t, err) + _, err = serviceStream.Recv() + require.ErrorIs(t, err, io.EOF) + require.NotEmpty(t, statuses) + for _, status := range statuses { + require.Equal(t, http.StatusOK, status) + } +} + +func TestSSETerminalErrorIsReturned(t *testing.T) { + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "event: error\ndata: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"watch failed"}}` + "`" + ` + "\n\n", + )), + }, goahttp.ResponseDecoder) + + _, err := stream.Recv() + var responseError *jsonrpc.ErrorResponse + require.ErrorAs(t, err, &responseError) + require.Equal(t, jsonrpc.InternalError, responseError.Code) + require.Equal(t, "watch failed", responseError.Message) +} + +func TestSSETerminalErrorPreservesCloseFailure(t *testing.T) { + closeErr := errors.New("close failed") + body := &failingResponseBody{ + reader: strings.NewReader( + "event: error\ndata: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"watch failed"}}` + "`" + ` + "\n\n", + ), + closeErr: closeErr, + } + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder) + + _, err := stream.Recv() + var responseError *jsonrpc.ErrorResponse + require.ErrorAs(t, err, &responseError) + require.Equal(t, "watch failed", responseError.Message) + require.ErrorIs(t, err, closeErr) + require.Equal(t, 1, body.closes) + _, err = stream.Recv() + require.Equal(t, io.EOF, err) +} + +func TestSSEInvalidEventClosesBody(t *testing.T) { + tests := []struct { + name string + event string + error string + }{ + {"malformed notification", "event: notification\ndata: {\n\n", "failed to parse notification"}, + {"wrong method", "event: notification\ndata: " + ` + "`" + `{"jsonrpc":"2.0","method":"other","params":{}}` + "`" + ` + "\n\n", "received notification for JSON-RPC method"}, + {"unsupported event", "event: other\ndata: {}\n\n", "unsupported server-sent event type"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := &failingResponseBody{reader: strings.NewReader(test.event)} + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder) + + _, err := stream.Recv() + require.ErrorContains(t, err, test.error) + require.Equal(t, 1, body.closes) + _, err = stream.Recv() + require.Equal(t, io.EOF, err) + }) + } +} + +func TestSSEReadFailureClosesBody(t *testing.T) { + readErr := errors.New("read failed") + body := &failingResponseBody{readErr: readErr} + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder) + + _, err := stream.Recv() + require.ErrorIs(t, err, readErr) + require.Equal(t, 1, body.closes) + _, err = stream.Recv() + require.Equal(t, io.EOF, err) +} + +func TestSSEValidNotificationKeepsBodyOpen(t *testing.T) { + body := &failingResponseBody{reader: strings.NewReader( + "event: notification\ndata: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + "\n\n" + + "event: response\ndata: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":null}` + "`" + ` + "\n\n", + )} + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder) + + _, err := stream.Recv() + require.NoError(t, err) + require.Zero(t, body.closes) + _, err = stream.Recv() + require.Equal(t, io.EOF, err) + require.Equal(t, 1, body.closes) +} + +func TestSSEEndpointReturnsResponseBodyFailures(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadGateway, + Header: make(http.Header), + Body: &failingResponseBody{readErr: readErr, closeErr: closeErr}, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + _, err := client.Watch()(context.Background(), nil) + assertDecodingError(t, err) + require.ErrorIs(t, err, readErr) + require.ErrorIs(t, err, closeErr) +} + +func TestSSEEndpointReturnsContentTypeAndCloseFailures(t *testing.T) { + closeErr := errors.New("close failed") + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: &failingResponseBody{closeErr: closeErr}, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.ErrorContains(t, err, "unexpected content type") + require.ErrorIs(t, err, closeErr) + assertDecodingError(t, err) +} + +func TestSSEEndpointContentTypeRemainsPlainWhenCloseSucceeds(t *testing.T) { + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: &failingResponseBody{}, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.EqualError(t, err, "unexpected content type: application/json (expected text/event-stream)") + var clientErr *goahttp.ClientError + require.NotErrorAs(t, err, &clientErr) +} + +func assertDecodingError(t *testing.T, err error) { + t.Helper() + var clientErr *goahttp.ClientError + require.ErrorAs(t, err, &clientErr) + require.Equal(t, "decoding_error", clientErr.Name) +} +` + +const sseCancellationRuntimeTest = `package client + +import ( + "context" + "errors" + "io" + "net/http" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" +) + +type blockingResponseBody struct { + readStarted chan struct{} + closed chan struct{} + readOnce sync.Once + closeOnce sync.Once + closeErr error + closes int +} + +func newBlockingResponseBody(closeErr error) *blockingResponseBody { + return &blockingResponseBody{ + readStarted: make(chan struct{}), + closed: make(chan struct{}), + closeErr: closeErr, + } +} + +func (body *blockingResponseBody) Read([]byte) (int, error) { + body.readOnce.Do(func() { + close(body.readStarted) + }) + <-body.closed + return 0, io.ErrClosedPipe +} + +func (body *blockingResponseBody) Close() error { + body.closeOnce.Do(func() { + body.closes++ + close(body.closed) + }) + return body.closeErr +} + +func TestRecvWithContextReturnsCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + assertBlockedReceiveEndsWithContext(t, ctx, cancel, context.Canceled) +} + +func TestRecvWithContextReturnsDeadlineExceeded(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + assertBlockedReceiveEndsWithContext(t, ctx, func() {}, context.DeadlineExceeded) +} + +func assertBlockedReceiveEndsWithContext(t *testing.T, ctx context.Context, endContext func(), want error) { + t.Helper() + closeErr := errors.New("close failed") + body := newBlockingResponseBody(closeErr) + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder) + + received := make(chan error, 1) + go func() { + _, err := stream.RecvWithContext(ctx) + received <- err + }() + <-body.readStarted + endContext() + + select { + case err := <-received: + require.ErrorIs(t, err, want) + require.ErrorIs(t, err, closeErr) + case <-time.After(time.Second): + require.NoError(t, body.Close()) + err := <-received + t.Fatalf("receive remained blocked after context ended; returned %v after closing body", err) + } + select { + case <-body.closed: + default: + t.Fatal("receive returned without closing response body") + } + _, err := stream.Recv() + require.ErrorIs(t, err, io.EOF) + require.Equal(t, 1, body.closes) +} +` + +const mappedBodyRuntimeTest = `package client + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func TestMappedObjectBodyAcceptsBothViews(t *testing.T) { + for _, view := range []string{"summary", "detailed"} { + response := mappedResponse(` + "`" + `{"view":"` + "`" + ` + view + ` + "`" + `","body":{"value":"record-1"}}` + "`" + `) + _, err := DecodeFetchResponse(goahttp.ResponseDecoder, false)(response) + require.NoError(t, err) + } +} + +func TestMappedObjectBodyRejectsMissingRequiredField(t *testing.T) { + response := mappedResponse(` + "`" + `{"view":"summary","body":{}}` + "`" + `) + _, err := DecodeFetchResponse(goahttp.ResponseDecoder, false)(response) + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, goa.MissingField, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, "value", *serviceError.Field) +} + +func mappedResponse(result string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":` + "`" + ` + result + "}", + )), + } +} +` + +const sseDecodeErrorRuntimeTest = `package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_decode" + goahttp "goa.design/goa/v3/http" +) + +var errWriteSSEError = errors.New("write SSE error") +var errEncodeSSE = errors.New("encode SSE event") +var errFlushSSE = errors.New("flush SSE event") + +type unusedService struct{} + +func (*unusedService) Watch(context.Context, *service.WatchPayload, service.WatchServerStream) error { + return nil +} + +type failingResponseWriter struct { + header http.Header + headerCalls int +} + +type stepResponseWriter struct { + header http.Header + headerCalls int + writes int + failWrite int + flushError error +} + +func (writer *stepResponseWriter) Header() http.Header { + return writer.header +} + +func (writer *stepResponseWriter) WriteHeader(int) { + writer.headerCalls++ +} + +func (writer *stepResponseWriter) Write(data []byte) (int, error) { + writer.writes++ + if writer.writes == writer.failWrite { + return 0, errWriteSSEError + } + return len(data), nil +} + +func (writer *stepResponseWriter) FlushError() error { + return writer.flushError +} + +func (writer *failingResponseWriter) Header() http.Header { + return writer.header +} + +func (writer *failingResponseWriter) WriteHeader(int) { + writer.headerCalls++ +} + +func (*failingResponseWriter) Write([]byte) (int, error) { + return 0, errWriteSSEError +} + +func TestSSERequestDecodeErrorReturnsWriteFailureOnce(t *testing.T) { + reported := make([]error, 0, 1) + server := New( + service.NewEndpoints(&unusedService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + }, + ) + writer := &failingResponseWriter{header: make(http.Header)} + request := httptest.NewRequest(http.MethodPost, "/decode", strings.NewReader( + ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":"watch","params":{}}` + "`" + `, + )) + server.ServeHTTP(writer, request) + + require.Len(t, reported, 1) + require.ErrorIs(t, reported[0], errWriteSSEError) + require.Equal(t, 1, writer.headerCalls) +} + +func TestSSEEventEncodesBeforeStartingResponse(t *testing.T) { + writer := &stepResponseWriter{header: make(http.Header)} + stream := &sseServerStream{ + w: writer, + encoder: func(context.Context, http.ResponseWriter) goahttp.Encoder { + return goahttp.EncodingFunc(func(any) error { return errEncodeSSE }) + }, + } + + err := stream.sendSSEEvent(context.Background(), "notification", map[string]any{"value": "one"}) + require.ErrorIs(t, err, errEncodeSSE) + require.Zero(t, writer.headerCalls) + require.Zero(t, writer.writes) +} + +func TestSSEEventReturnsEveryWriteAndFlushError(t *testing.T) { + tests := []struct { + name string + failWrite int + flushError error + }{ + {name: "event name", failWrite: 1}, + {name: "data label", failWrite: 2}, + {name: "encoded value", failWrite: 3}, + {name: "event ending", failWrite: 4}, + {name: "flush", flushError: errFlushSSE}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + writer := &stepResponseWriter{ + header: make(http.Header), + failWrite: test.failWrite, + flushError: test.flushError, + } + stream := &sseServerStream{w: writer, encoder: goahttp.ResponseEncoder} + + err := stream.sendSSEEvent(context.Background(), "notification", map[string]any{"value": "one"}) + if test.flushError != nil { + require.ErrorIs(t, err, test.flushError) + } else { + require.ErrorIs(t, err, errWriteSSEError) + } + require.Equal(t, 1, writer.headerCalls) + }) + } +} +` + +const requestBodyFailureRuntimeTest = `package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + goahttp "goa.design/goa/v3/http" +) + +type failingRequestBody struct { + readErr error + closeErr error +} + +type failingResponseWriter struct { + *httptest.ResponseRecorder + failAt int + writes int + writeErr error +} + +func (writer *failingResponseWriter) Write(data []byte) (int, error) { + writer.writes++ + if writer.writes == writer.failAt { + return 0, writer.writeErr + } + return writer.ResponseRecorder.Write(data) +} + +func (body *failingRequestBody) Read([]byte) (int, error) { + return 0, body.readErr +} + +func (body *failingRequestBody) Close() error { + return body.closeErr +} + +func TestServerReportsRequestReadAndCloseFailures(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + var reported error + server := &Server{ + errhandler: func(_ context.Context, _ http.ResponseWriter, err error) { + reported = err + }, + } + request := httptest.NewRequest(http.MethodPost, "/unary", nil) + request.Body = &failingRequestBody{readErr: readErr, closeErr: closeErr} + + server.handleHTTP(httptest.NewRecorder(), request) + + require.ErrorIs(t, reported, readErr) + require.ErrorIs(t, reported, closeErr) +} + +func TestBatchWriterReturnsOpeningDelimiterFailure(t *testing.T) { + writeErr := errors.New("write failed") + response := &failingResponseWriter{ + ResponseRecorder: httptest.NewRecorder(), + failAt: 1, + writeErr: writeErr, + } + writer := &batchWriter{Writer: response} + + _, err := writer.Write([]byte("{\"jsonrpc\":\"2.0\",\"result\":null}")) + + require.ErrorIs(t, err, writeErr) +} + +func TestServerReportsBatchClosingDelimiterFailure(t *testing.T) { + writeErr := errors.New("write failed") + var reported []error + server := &Server{ + decoder: goahttp.RequestDecoder, + encoder: goahttp.ResponseEncoder, + errhandler: func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + }, + } + request := httptest.NewRequest( + http.MethodPost, + "/unary", + strings.NewReader("[{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"missing\"}]"), + ) + response := &failingResponseWriter{ + ResponseRecorder: httptest.NewRecorder(), + failAt: 3, + writeErr: writeErr, + } + + server.handleHTTP(response, request) + + require.ErrorIs(t, errors.Join(reported...), writeErr) +} +` + +const sseLifecycleRuntimeTest = `package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_decode" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +var errWatch = goa.NewServiceError(errors.New("watch failed"), "watch_failed", false, false, false) + +type lifecycleService struct { + fail bool +} + +func (s *lifecycleService) Watch(_ context.Context, _ *service.WatchPayload, stream service.WatchServerStream) error { + if err := stream.Send(&service.WatchResult{Message: "ready"}); err != nil { + return err + } + if s.fail { + return errWatch + } + return nil +} + +func TestSSEStreamWritesNotificationThenNullCompletion(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{}, ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":"watch","params":{"topic":"alerts"}}` + "`" + `) + require.Empty(t, reported) + notification := strings.Index(body, "event: notification") + response := strings.Index(body, "event: response") + require.NotEqual(t, -1, notification) + require.Greater(t, response, notification) + require.Contains(t, body, ` + "`" + `"method":"watch"` + "`" + `) + require.Contains(t, body, ` + "`" + `"params":{"message":"ready"}` + "`" + `) + require.Contains(t, body, ` + "`" + `"id":"request-1"` + "`" + `) + require.Contains(t, body, ` + "`" + `"result":null` + "`" + `) +} + +func TestSSEStreamWritesReturnedErrorAsTerminalResponse(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{fail: true}, ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":"watch","params":{"topic":"alerts"}}` + "`" + `) + require.Empty(t, reported) + notification := strings.Index(body, "event: notification") + response := strings.Index(body, "event: error") + require.NotEqual(t, -1, notification) + require.Greater(t, response, notification) + require.Contains(t, body, ` + "`" + `"code":-32603` + "`" + `) + require.Contains(t, body, ` + "`" + `"message":"watch failed"` + "`" + `) +} + +func TestSSENotificationRequestHasNoTerminalResponse(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{}, ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"topic":"alerts"}}` + "`" + `) + require.Empty(t, reported) + require.Contains(t, body, "event: notification") + require.NotContains(t, body, "event: response") + require.NotContains(t, body, "event: error") +} + +func TestSSENotificationServiceErrorHasNoTerminalResponse(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{fail: true}, ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"topic":"alerts"}}` + "`" + `) + require.Empty(t, reported) + require.Contains(t, body, "event: notification") + require.NotContains(t, body, "event: response") + require.NotContains(t, body, "event: error") +} + +func TestSSENotificationDecodeErrorHasNoResponse(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{}, ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{}}` + "`" + `) + require.Empty(t, reported) + require.Empty(t, body) +} + +func TestSSEUnknownNotificationReceivesNoResponse(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{}, ` + "`" + `{"jsonrpc":"2.0","method":"missing"}` + "`" + `) + require.Empty(t, reported) + require.Empty(t, body) +} + +func TestSSEInvalidRequestsReceiveError(t *testing.T) { + for _, request := range []string{ + ` + "`" + `{"jsonrpc":"2.0"}` + "`" + `, + ` + "`" + `{"jsonrpc":"1.0","method":"watch","params":{"topic":"alerts"}}` + "`" + `, + } { + body, reported := serveLifecycle(&lifecycleService{}, request) + require.Empty(t, reported) + require.Contains(t, body, "event: error") + require.Contains(t, body, ` + "`" + `"id":null` + "`" + `) + require.Contains(t, body, ` + "`" + `"code":-32600` + "`" + `) + } +} + +func serveLifecycle(svc *lifecycleService, body string) (string, []error) { + reported := make([]error, 0, 1) + server := New( + service.NewEndpoints(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + }, + ) + writer := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/decode", strings.NewReader(body)) + server.ServeHTTP(writer, request) + return writer.Body.String(), reported +} +` + +const unaryMetadataRuntimeTest = `package server + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/unary_metadata" + genclient "generated.local/gen/jsonrpc/unary_metadata/client" + goahttp "goa.design/goa/v3/http" +) + +type metadataService struct { + fetchView string +} + +func (s *metadataService) Fetch(context.Context) (*service.UnaryMetadata, string, error) { + value := "record-1" + return &service.UnaryMetadata{Value: &value, Etag: "etag-1", Session: "session-1"}, s.fetchView, nil +} + +func (*metadataService) Only(context.Context) (*service.UnaryMetadataOnly, error) { + return &service.UnaryMetadataOnly{Etag: "etag-2", Session: "session-2"}, nil +} + +func TestViewedUnaryResponseCarriesBodyHeaderAndCookie(t *testing.T) { + response := serve(t, &metadataService{fetchView: "summary"}, "fetch") + require.Equal(t, "etag-1", response.Header.Get("X-ETag")) + cookies := response.Cookies() + require.Len(t, cookies, 1) + require.Equal(t, "SID", cookies[0].Name) + require.Equal(t, "session-1", cookies[0].Value) + + decoded, err := genclient.DecodeFetchResponse(goahttp.ResponseDecoder, false)(response) + require.NoError(t, err) + result := decoded.(*service.UnaryMetadata) + require.Equal(t, "record-1", *result.Value) + require.Equal(t, "etag-1", result.Etag) + require.Equal(t, "session-1", result.Session) +} + +func TestViewedUnaryResponseCarriesOnlyHeaderAndCookie(t *testing.T) { + response := serve(t, &metadataService{}, "only") + require.Equal(t, "etag-2", response.Header.Get("X-ETag")) + cookies := response.Cookies() + require.Len(t, cookies, 1) + require.Equal(t, "SID", cookies[0].Name) + require.Equal(t, "session-2", cookies[0].Value) + + decoded, err := genclient.DecodeOnlyResponse(goahttp.ResponseDecoder, false)(response) + require.NoError(t, err) + result := decoded.(*service.UnaryMetadataOnly) + require.Equal(t, "etag-2", result.Etag) + require.Equal(t, "session-2", result.Session) +} + +func TestUnknownViewWritesNoSuccessMetadata(t *testing.T) { + response := serve(t, &metadataService{fetchView: "unknown"}, "fetch") + require.Empty(t, response.Header.Get("X-ETag")) + require.Empty(t, response.Cookies()) + + _, err := genclient.DecodeFetchResponse(goahttp.ResponseDecoder, false)(response) + require.Error(t, err) +} + +func serve(t *testing.T, svc *metadataService, method string) *http.Response { + t.Helper() + server := New( + service.NewEndpoints(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + ) + body := []byte(` + "`" + `{"jsonrpc":"2.0","id":"1","method":"` + "`" + ` + method + ` + "`" + `"}` + "`" + `) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/metadata", bytes.NewReader(body))) + require.Equal(t, http.StatusOK, recorder.Code) + return recorder.Result() +} +` diff --git a/jsonrpc/codegen/websocket_client.go b/jsonrpc/codegen/websocket_client.go deleted file mode 100644 index 13c1751328..0000000000 --- a/jsonrpc/codegen/websocket_client.go +++ /dev/null @@ -1,88 +0,0 @@ -package codegen - -import ( - "fmt" - "path/filepath" - - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" - httpcodegen "goa.design/goa/v3/http/codegen" -) - -func websocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) - if !httpcodegen.HasWebSocket(data) { - return nil - } - - svcName := data.Service.PathName - title := fmt.Sprintf("%s WebSocket JSON-RPC client", svc.Name()) - - // Build imports list for WebSocket clients - imports := make([]*codegen.ImportSpec, 0, 15+len(data.Service.UserTypeImports)) - imports = append(imports, - &codegen.ImportSpec{Path: "bytes"}, - &codegen.ImportSpec{Path: "context"}, - &codegen.ImportSpec{Path: "encoding/json"}, - &codegen.ImportSpec{Path: "fmt"}, - &codegen.ImportSpec{Path: "io"}, - &codegen.ImportSpec{Path: "net/http"}, - &codegen.ImportSpec{Path: "strconv"}, - &codegen.ImportSpec{Path: "sync"}, - &codegen.ImportSpec{Path: "sync/atomic"}, - &codegen.ImportSpec{Path: "time"}, - &codegen.ImportSpec{Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaImport("jsonrpc"), - codegen.GoaNamedImport("http", "goahttp"), - &codegen.ImportSpec{Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - ) - imports = append(imports, data.Service.UserTypeImports...) - - sections := []*codegen.SectionTemplate{ - codegen.Header(title, "client", imports), - } - - // Add common error handling types for all streams - sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-websocket-stream-error-types", - Source: jsonrpcTemplates.Read(websocketStreamErrorTypesT), - }) - - // Process only WebSocket endpoints and generate stream implementations only - for _, e := range data.Endpoints { - if !httpcodegen.IsWebSocketEndpoint(e) { - continue - } - - // Add stream implementation (endpoint methods are in client.go) - sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-websocket-client-stream", - Source: jsonrpcTemplates.Read(websocketClientStreamT), - Data: e.ClientWebSocket, - }) - } - - return &codegen.File{ - Path: filepath.Join(codegen.Gendir, "jsonrpc", svcName, "client", "websocket.go"), - SectionTemplates: sections, - } -} - -// allErrors returns all errors for the given service. -func allErrors(data *httpcodegen.ServiceData) []*httpcodegen.ErrorData { - seen := make(map[string]struct{}) - var errors []*httpcodegen.ErrorData - for _, e := range data.Endpoints { - for _, gerr := range e.Errors { - for _, err := range gerr.Errors { - if _, ok := seen[err.Name]; ok { - continue - } - seen[err.Name] = struct{}{} - errors = append(errors, err) - } - } - } - return errors -} diff --git a/jsonrpc/codegen/websocket_server.go b/jsonrpc/codegen/websocket_server.go deleted file mode 100644 index d463ac4c8f..0000000000 --- a/jsonrpc/codegen/websocket_server.go +++ /dev/null @@ -1,83 +0,0 @@ -package codegen - -import ( - "fmt" - "path/filepath" - - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" - httpcodegen "goa.design/goa/v3/http/codegen" -) - -// websocketServerFile returns the file implementing the JSON-RPC WebSocket server -// streaming implementation if any. It follows the exact same pattern as the encode/decode -// files: get the HTTP file and modify it for JSON-RPC. -func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) - if !httpcodegen.HasWebSocket(data) { - return nil - } - funcs := map[string]any{ - "lowerInitial": lowerInitial, - "allErrors": allErrors, - "isWebSocketEndpoint": httpcodegen.IsWebSocketEndpoint, - } - svcName := data.Service.PathName - title := fmt.Sprintf("%s WebSocket server streaming", svc.Name()) - imports := make([]*codegen.ImportSpec, 0, 14+len(data.Service.UserTypeImports)) - imports = append(imports, - &codegen.ImportSpec{Path: "context"}, - &codegen.ImportSpec{Path: "encoding/json"}, - &codegen.ImportSpec{Path: "errors"}, - &codegen.ImportSpec{Path: "fmt"}, - &codegen.ImportSpec{Path: "io"}, - &codegen.ImportSpec{Path: "net/http"}, - &codegen.ImportSpec{Path: "strings"}, - &codegen.ImportSpec{Path: "sync"}, - &codegen.ImportSpec{Path: "time"}, - &codegen.ImportSpec{Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaImport("jsonrpc"), - codegen.GoaNamedImport("http", "goahttp"), - &codegen.ImportSpec{Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - ) - imports = append(imports, data.Service.UserTypeImports...) - sections := []*codegen.SectionTemplate{ - codegen.Header(title, "server", imports), - { - Name: "jsonrpc-server-websocket-struct", - Source: jsonrpcTemplates.Read(websocketServerStreamT), - Data: data, - FuncMap: funcs, - }, - { - Name: "jsonrpc-server-websocket-stream-wrapper", - Source: jsonrpcTemplates.Read(websocketServerStreamWrapperT), - Data: data, - FuncMap: funcs, - }, - { - Name: "jsonrpc-server-websocket-send", - Source: jsonrpcTemplates.Read(websocketServerSendT), - Data: data, - FuncMap: funcs, - }, - { - Name: "jsonrpc-server-websocket-recv", - Source: jsonrpcTemplates.Read(websocketServerRecvT), - Data: data, - FuncMap: funcs, - }, - { - Name: "jsonrpc-server-websocket-close", - Source: jsonrpcTemplates.Read(websocketServerCloseT), - Data: data, - FuncMap: funcs, - }, - } - - return &codegen.File{ - Path: filepath.Join(codegen.Gendir, "jsonrpc", svcName, "server", "websocket.go"), - SectionTemplates: sections, - } -} diff --git a/jsonrpc/doc.go b/jsonrpc/doc.go index 7f4c1d1f6f..f31966dc2e 100644 --- a/jsonrpc/doc.go +++ b/jsonrpc/doc.go @@ -12,7 +12,7 @@ // - Notification requests (fire-and-forget) // - Batch requests for multiple calls // - Structured error handling with error codes -// - HTTP, Server-Sent Events (SSE) and WebSocket transports +// - HTTP requests and server streams sent as Server-Sent Events (SSE) // // Code generated by Goa uses this package to create JSON-RPC clients and // servers that seamlessly integrate with Goa's design-first approach and diff --git a/jsonrpc/integration_tests/README.md b/jsonrpc/integration_tests/README.md index eac368734e..9b5ad2f45d 100644 --- a/jsonrpc/integration_tests/README.md +++ b/jsonrpc/integration_tests/README.md @@ -202,32 +202,10 @@ Use `request` to initiate, `sequence` for the event stream: # ... more events ``` -#### WebSocket Tests (Bidirectional Messages) -Use only `sequence` for back-and-forth communication: -```yaml -- name: "websocket_test" - method: "echo_string_ws" - transport: "websocket" - sequence: # Series of sends and receives - - type: "connect" # Optional: explicit connection - - type: "send" - data: - method: "echo_string_ws" - params: - id: "ws-1" - value: "hello" - id: "ws-1" - - type: "receive" - expect: - id: "ws-1" - result: - value: "hello" - - type: "close" # Close the connection -``` - ## 📜 Method Naming Convention -Server behavior is determined entirely by the method name, which follows the pattern: `[action]_[type]_[modifier]`. +Server behavior is determined entirely by the method name. Unary methods use +`[action]_[type]_[modifier]`; SSE methods add the `_sse` suffix. ### Quick Reference Table @@ -251,9 +229,7 @@ Server behavior is determined entirely by the method name, which follows the pat * `echo`: Returns the `params` payload exactly as it was received. * `transform`: Returns a predictably modified version of the `params`. * `generate`: Ignores `params` and returns a fixed, predictable value. - * `stream`: (SSE/WebSocket) Sends a stream of messages to the client. Ideal for testing server-streaming RPC. - * `collect`: (WebSocket) Receives a stream of messages from a client and returns a single summary response after the stream is closed. Useful for testing client-streaming RPC. - * `broadcast`: (WebSocket) Tests the server's ability to send unsolicited messages to a client (server-initiated notifications). + * `stream`: Sends a stream of server-sent events to the client. ### Types and Their Structure @@ -292,7 +268,6 @@ Server behavior is determined entirely by the method name, which follows the pat * `_notify`: Indicates a JSON-RPC notification (no response expected). * `_error`: The method is hardcoded to always return a predefined JSON-RPC error. * `_validate`: The method includes Goa validation logic on the payload, which will return an error if the payload is invalid. - * `_final`: (SSE) The method sends several notifications before sending a final, ID-tagged response. ## 📊 Data-Driven Behavior @@ -347,7 +322,7 @@ sequence: value: "generated-3" ``` -#### `stream` Action (SSE/WebSocket) +#### `stream` Action (SSE) The payload data controls the streaming behavior: **For `string` type:** @@ -433,29 +408,6 @@ sequence: ### Modifier Effects -**`_final` modifier (SSE):** -Sends notifications followed by a final response with the request ID: - -```yaml -# Example: stream_string_final_sse -request: - params: "ab" # 2 characters = 2 notifications - id: "req-1" -sequence: - - expect: # Notification (no ID) - method: "stream_string_final_sse" - params: - value: "Stream 1 of 2" - - expect: # Notification (no ID) - method: "stream_string_final_sse" - params: - value: "Stream 2 of 2" - - expect: # Final response (with ID) - id: "req-1" - result: - value: "Final response" -``` - **`_error` modifier:** For streaming, sends notifications then returns an error: @@ -563,11 +515,11 @@ Each item in the top-level `scenarios` list is a `Scenario` object. It defines a | Key | Type | Required? | Description | | :--- | :--- | :--- | :--- | | **`name`** | `string` | **Yes** | A unique, human-readable name for the test. Used in test runner output. | -| **`method`** | `string` | **Yes** | The name of the server method to test. Must follow the `action_type_modifier` convention. | -| **`transport`** | `string` | **Yes** | The transport protocol. Must be one of `"http"`, `"websocket"`, or `"sse"`. | +| **`method`** | `string` | **Yes** | The server method. SSE method names end with `_sse`. | +| **`transport`** | `string` | **Yes** | The transport protocol. Must be either `"http"` or `"sse"`. | | `request` | `object` | Conditional | An object describing the request to send. **Required** for non-streaming (`http`) tests. | | `expect` | `object` | Conditional | An object describing the expected response. **Required** for non-streaming (`http`) tests. | -| `sequence` | `list` | Conditional | A list of steps for stateful interactions. **Required** for streaming (`websocket`, `sse`) tests. | +| `sequence` | `list` | Conditional | The events expected from an `sse` stream. | > A `Scenario` object must contain **either** a `request`/`expect` pair **or** a `sequence`, but not both. @@ -598,8 +550,7 @@ Each item in a `sequence` list is a step object that defines a single action in | Key | Type | Required? | Description | | :--- | :--- | :--- | :--- | -| **`type`** | `string` | **Yes** | The type of action. Must be one of `"send"`, `"receive"`, or `"close"`. | -| `data` | `object` | Conditional | The JSON-RPC payload to send. **Required** for `type: "send"`. | +| **`type`** | `string` | **Yes** | The event action. SSE sequences use `"receive"`. | | `expect`| `object` | Conditional | The expected JSON-RPC payload to receive. **Required** for `type: "receive"`. | | `delay` | `string` | No | A duration to wait before executing this step (e.g., `"100ms"`, `"1s"`). | @@ -632,7 +583,7 @@ In addition to the structure, the content of the YAML file must adhere to these * **Exclusivity**: A scenario cannot have both `request`/`expect` and `sequence` defined. * **ID Matching**: If a `request.id` is present, the corresponding `expect.id` must be identical. * **Result vs. Error**: An `expect` object cannot define both a `result` and an `error`. - * **Method Convention**: The `method` field must follow the `[action]_[type]_[modifier]` pattern, which determines the generated server's behavior. + * **Method Convention**: Unary methods follow `[action]_[type]_[modifier]`; SSE methods end with `_sse`. ## 🌐 Complete Examples @@ -652,39 +603,3 @@ scenarios: code: -32000 message: "A simulated server error occurred" ``` - -### WebSocket Bidirectional Streaming - -This example shows a client subscribing to a channel and then receiving a server-initiated broadcast. - -```yaml -scenarios: - - name: "broadcast_websocket_interaction" - method: "broadcast_string" - transport: "websocket" - sequence: - # 1. Client sends a subscription request - - type: "send" - data: - jsonrpc: "2.0" - method: "broadcast_string" # Method to call on the server - params: { "channel": "news" } - id: "sub-1" - - # 2. Client expects a confirmation response - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sub-1" - result: { "status": "subscribed", "channel": "news" } - - # 3. Client waits to receive an unsolicited broadcast from the server - - type: "receive" - expect: - jsonrpc: "2.0" - method: "broadcast" # Note: This is a server-initiated method, not a response - params: { "message": "Server update!" } -``` - - -Review each file one by one and each function one by one and think of ways it can be streamlined, improved, simplify, made more tuitive and follow Go best practice.  \ No newline at end of file diff --git a/jsonrpc/integration_tests/framework/codegen_data.go b/jsonrpc/integration_tests/framework/codegen_data.go index 98a3523993..dfb0bbabd5 100644 --- a/jsonrpc/integration_tests/framework/codegen_data.go +++ b/jsonrpc/integration_tests/framework/codegen_data.go @@ -44,9 +44,8 @@ type MethodData struct { Info MethodInfo // Type information - Payload *TypeSpec // Initial payload (if any) - StreamingPayload *TypeSpec // Streaming payload (if any) - Result *TypeSpec // Result - can be regular or streaming + Payload *TypeSpec // Request payload, if any + Result *TypeSpec // Unary result or streamed event // Behavior flags IsNotification bool // No response expected @@ -55,11 +54,8 @@ type MethodData struct { // Streaming information IsStreaming bool - StreamKind string // "payload", "result", "bidirectional" - Transport string // "http", "sse", "ws" + Transport string // "http" or "sse" - // For SSE with final response - HasFinalResponse bool } // TypeSpec describes a type semantically @@ -79,9 +75,6 @@ type TypeSpec struct { // For maps MapKey *TypeSpec MapValue *TypeSpec - - // Whether this type needs ID field (for bidirectional WebSocket) - NeedsID bool } // FieldSpec describes a field in an object @@ -130,34 +123,9 @@ type MethodImplData struct { StreamInterface string } -// ActionBehavior describes how a method should behave based on its action -type ActionBehavior struct { - // Action type (echo, transform, generate, collect, stream, broadcast) - Action string - // Type being operated on (string, array, object, map) - Type string - // Additional context (e.g., for streaming methods) - Context map[string]any -} - // Helper methods // IsSSE returns true if this method uses SSE transport func (m *MethodData) IsSSE() bool { return m.Transport == "sse" } - -// IsWebSocket returns true if this method uses WebSocket transport -func (m *MethodData) IsWebSocket() bool { - return m.Transport == "ws" -} - -// IsBidirectional returns true if this is a bidirectional streaming method -func (m *MethodData) IsBidirectional() bool { - return m.StreamKind == "bidirectional" -} - -// NeedsStreamingService returns true if this method requires a separate streaming service -func (m *MethodData) NeedsStreamingService() bool { - return m.IsStreaming && (m.IsSSE() || m.IsWebSocket()) -} diff --git a/jsonrpc/integration_tests/framework/constants.go b/jsonrpc/integration_tests/framework/constants.go index c729ab9107..e2a52eaa5a 100644 --- a/jsonrpc/integration_tests/framework/constants.go +++ b/jsonrpc/integration_tests/framework/constants.go @@ -2,9 +2,8 @@ package framework // Transport constants define available transport protocols const ( - TransportHTTP = "http" - TransportWebSocket = "websocket" - TransportSSE = "sse" + TransportHTTP = "http" + TransportSSE = "sse" ) // Action constants define server behavior patterns @@ -12,9 +11,7 @@ const ( ActionEcho = "echo" // Returns input unchanged ActionTransform = "transform" // Modifies input predictably ActionGenerate = "generate" // Returns fixed values - ActionStream = "stream" // Server-side streaming - ActionCollect = "collect" // Client-side streaming - ActionBroadcast = "broadcast" // Server-initiated messages + ActionStream = "stream" // Sends results with server-sent events ) // Type constants define data structures @@ -33,6 +30,5 @@ const ( ModifierNotify = "notify" // No response expected ModifierError = "error" // Always returns error ModifierValidate = "validate" // Includes validation - ModifierFinal = "final" // SSE: final response ModifierIDMap = "idmap" // Map envelope ID to payload/result field ) diff --git a/jsonrpc/integration_tests/framework/executor.go b/jsonrpc/integration_tests/framework/executor.go index 01af486778..c7f17d5a47 100644 --- a/jsonrpc/integration_tests/framework/executor.go +++ b/jsonrpc/integration_tests/framework/executor.go @@ -5,7 +5,6 @@ import ( "encoding/json" "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,8 +20,7 @@ type executor struct { // newExecutor creates a new test executor func newExecutor(serverURL string, opts ...executorOption) *executor { config := executorConfig{ - WebSocketTimeout: 30 * time.Second, - Debug: false, + Debug: false, } for _, opt := range opts { @@ -62,8 +60,6 @@ func (e *executor) executeSimple(t *testing.T, scenario Scenario) { switch scenario.Transport { case TransportHTTP: e.executeHTTP(ctx, t, scenario) - case TransportWebSocket: - e.executeWebSocket(ctx, t, scenario) case TransportSSE: e.executeSSE(ctx, t, scenario) default: @@ -169,35 +165,6 @@ func (e *executor) executeHTTP(ctx context.Context, t *testing.T, scenario Scena } } -// executeWebSocket handles WebSocket transport scenarios -func (e *executor) executeWebSocket(ctx context.Context, t *testing.T, scenario Scenario) { - t.Helper() - - // WebSocket scenarios always use sequence - if len(scenario.Sequence) > 0 { - e.executeWebSocketSequence(ctx, t, scenario) - return - } - - // If no sequence, create a simple send/receive sequence from request/expect - if scenario.Request.Params != nil { - // Pass method, params, and id as separate fields - data := map[string]any{ - "method": scenario.Method, - "params": scenario.Request.Params, - } - if scenario.Request.ID != nil { - data["id"] = scenario.Request.ID - } - - scenario.Sequence = []Action{ - {Type: "send", Data: data}, - {Type: "receive", Expect: scenario.Expect}, - } - e.executeWebSocketSequence(ctx, t, scenario) - } -} - // executeSSE handles Server-Sent Events scenarios func (e *executor) executeSSE(_ context.Context, t *testing.T, _ Scenario) { t.Helper() @@ -213,10 +180,8 @@ func (e *executor) executeStreaming(t *testing.T, scenario Scenario) { ctx := context.Background() - // Only WebSocket and SSE support streaming + // JSON-RPC streaming uses server-sent events. switch scenario.Transport { - case TransportWebSocket: - e.executeWebSocketSequence(ctx, t, scenario) case TransportSSE: e.executeSSESequence(ctx, t, scenario) default: @@ -224,91 +189,6 @@ func (e *executor) executeStreaming(t *testing.T, scenario Scenario) { } } -// executeWebSocketSequence handles WebSocket streaming sequences -func (e *executor) executeWebSocketSequence(ctx context.Context, t *testing.T, scenario Scenario) { - t.Helper() - - client, err := harness.NewClient(e.serverURL, nil) - require.NoError(t, err, "Failed to create client") - - // Execute sequence steps - for i, step := range scenario.Sequence { - switch step.Type { - case "connect": - err := client.ConnectWebSocket(ctx) - require.NoErrorf(t, err, "Step %d: failed to connect WebSocket", i) - - case "send": - // Auto-connect if not connected - if !client.IsConnected() { - err := client.ConnectWebSocket(ctx) - require.NoErrorf(t, err, "Step %d: failed to auto-connect WebSocket", i) - } - - require.NotNilf(t, step.Data, "Step %d: send step requires data", i) - - // Extract method, params, and id from the data - reqData, ok := step.Data.(map[string]any) - require.Truef(t, ok, "Step %d: invalid request data format", i) - - req := harness.JSONRPCRequest{ - Method: reqData["method"].(string), - Params: reqData["params"], - ID: reqData["id"], - } - - // Mark HasID when id key present (even if it's null) - if _, hasID := reqData["id"]; hasID { - req.HasID = true - } - - // Handle custom jsonrpc field if specified - if jsonrpcVal, ok := reqData["jsonrpc"]; ok { - if jsonrpcStr, ok := jsonrpcVal.(string); ok { - if jsonrpcStr == "-" { - // Special value to omit the field - emptyStr := "" - req.JSONRPC = &emptyStr - } else { - req.JSONRPC = &jsonrpcStr - } - } - } - // If not specified, JSONRPC remains nil and defaults to "2.0" - - err := client.SendWebSocket(ctx, req) - require.NoErrorf(t, err, "Step %d: failed to send", i) - - case "receive": - msg, err := client.ReceiveWebSocket(ctx) - require.NoErrorf(t, err, "Step %d: failed to receive", i) - - var response map[string]any - err = json.Unmarshal(msg, &response) - require.NoErrorf(t, err, "Step %d: failed to unmarshal response", i) - - // Compare the response with expected - if expected, ok := step.Expect.(map[string]any); ok { - e.compareJSONRPCMessages(t, response, expected) - } else { - require.Failf(t, "Invalid expected value", "Step %d: expected value must be a map", i) - } - - case "close": - err := client.CloseWebSocket() - require.NoErrorf(t, err, "Step %d: failed to close WebSocket", i) - - default: - require.Failf(t, "Unknown step type", "Step %d: unknown step type: %s", i, step.Type) - } - - // Apply delay if specified - if step.Delay > 0 { - time.Sleep(step.Delay) - } - } -} - // executeSSESequence handles SSE streaming sequences func (e *executor) executeSSESequence(ctx context.Context, t *testing.T, scenario Scenario) { t.Helper() @@ -480,7 +360,7 @@ func (e *executor) validateJSONRPCResponse(t *testing.T, response any, expect Ex } } -// compareJSONRPCMessages compares two JSON-RPC messages (used for SSE/WebSocket validation) +// compareJSONRPCMessages compares two JSON-RPC messages from an SSE stream. func (e *executor) compareJSONRPCMessages(t *testing.T, actual, expected map[string]any) { t.Helper() diff --git a/jsonrpc/integration_tests/framework/framework_test.go b/jsonrpc/integration_tests/framework/framework_test.go index 5d743f5a58..9b9ca6012b 100644 --- a/jsonrpc/integration_tests/framework/framework_test.go +++ b/jsonrpc/integration_tests/framework/framework_test.go @@ -2,6 +2,8 @@ package framework import ( "testing" + + "github.com/stretchr/testify/require" ) // TestParseMethod verifies method name parsing @@ -19,8 +21,11 @@ func TestParseMethod(t *testing.T) { {"generate_object", "generate", "object", "", false}, {"echo_string_notify", "echo", "string", "notify", false}, {"transform_map_error", "transform", "map", "error", false}, - {"stream_string_final", "stream", "string", "final", false}, - + {"stream_string_sse", "stream", "string", "", false}, + {"stream_string_unknown_sse", "", "", "", true}, + {"stream_string", "", "", "", true}, + {"echo_string_ws", "", "", "", true}, + // Invalid methods {"invalid", "", "", "", true}, {"echo", "", "", "", true}, @@ -29,7 +34,7 @@ func TestParseMethod(t *testing.T) { {"", "", "", "", true}, {"echo__string", "", "", "", true}, } - + for _, tt := range tests { t.Run(tt.method, func(t *testing.T) { info, err := ParseMethod(tt.method) @@ -39,11 +44,11 @@ func TestParseMethod(t *testing.T) { } return } - + if err != nil { t.Fatalf("ParseMethod(%q) failed: %v", tt.method, err) } - + if info.Action != tt.action { t.Errorf("Action: got %q, want %q", info.Action, tt.action) } @@ -55,4 +60,31 @@ func TestParseMethod(t *testing.T) { } }) } -} \ No newline at end of file +} + +// TestScenariosUseSupportedTransports checks that every checked-in scenario +// uses HTTP or server-sent events and that each server-sent event scenario +// contains only receive steps and ends with its request ID when one is present. +func TestScenariosUseSupportedTransports(t *testing.T) { + runner, err := NewRunner("../scenarios/scenarios.yaml") + require.NoError(t, err) + + for _, scenario := range runner.config.Scenarios { + t.Run(scenario.Name, func(t *testing.T) { + require.Contains(t, []string{TransportHTTP, TransportSSE}, scenario.Transport) + if scenario.Transport != TransportSSE { + return + } + for _, step := range scenario.Sequence { + require.Equal(t, "receive", step.Type) + } + if scenario.Request.ID == nil { + return + } + require.NotEmpty(t, scenario.Sequence) + response, ok := scenario.Sequence[len(scenario.Sequence)-1].Expect.(map[string]any) + require.True(t, ok) + require.EqualValues(t, scenario.Request.ID, response["id"]) + }) + } +} diff --git a/jsonrpc/integration_tests/framework/generator.go b/jsonrpc/integration_tests/framework/generator.go index fb082ed02b..047d733f18 100644 --- a/jsonrpc/integration_tests/framework/generator.go +++ b/jsonrpc/integration_tests/framework/generator.go @@ -128,39 +128,21 @@ func (g *Generator) renderImplementation(impl *ImplementationData) error { // buildMethodData creates semantic data for a method. func (g *Generator) buildMethodData(info MethodInfo) *MethodData { data := &MethodData{ - Name: info.Name(), - GoName: goify(info.Name()), - Description: g.getMethodDescription(info), - Info: info, - IsNotification: info.Modifier == ModifierNotify, - ReturnsError: info.Modifier == ModifierError, - HasValidation: info.Modifier == ModifierValidate, - HasFinalResponse: info.Modifier == ModifierFinal, - Transport: info.Transport, - IsStreaming: info.IsStreaming(), - } - // Non-streaming payload - if info.Modifier != ModifierNotify && info.Action != ActionGenerate && (!info.HasStreamingPayload() || info.IsSSE()) { + Name: info.Name(), + GoName: goify(info.Name()), + Description: g.getMethodDescription(info), + Info: info, + IsNotification: info.Modifier == ModifierNotify, + ReturnsError: info.Modifier == ModifierError, + HasValidation: info.Modifier == ModifierValidate, + Transport: info.Transport, + IsStreaming: info.IsStreaming(), + } + if info.Modifier != ModifierNotify && info.Action != ActionGenerate { data.Payload = g.buildTypeSpec(info.Type, info.Modifier) } - // Streaming if info.IsStreaming() { - isBidi := info.IsWebSocket() && info.HasStreamingPayload() && info.HasStreamingResult() - if info.HasStreamingPayload() { - data.StreamingPayload = g.buildStreamingTypeSpec(info.Type, true, isBidi, info) - data.StreamKind = "payload" - } - if info.HasStreamingResult() { - data.Result = g.buildStreamingTypeSpec(info.Type, false, isBidi, info) - if data.StreamKind == "payload" { - data.StreamKind = "bidirectional" - } else { - data.StreamKind = "result" - } - if info.IsSSE() && info.Modifier == ModifierFinal && data.Result != nil { - data.Result.Fields = append(data.Result.Fields, FieldSpec{Position: len(data.Result.Fields) + 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Description: "Response ID (for final response)", Required: false}) - } - } + data.Result = g.buildStreamingTypeSpec(info.Type) } else if info.Modifier != ModifierNotify && info.Modifier != ModifierError { data.Result = g.buildTypeSpec(info.Type, "") } @@ -245,62 +227,9 @@ func (g *Generator) buildTypeSpec(typeStr, modifier string) *TypeSpec { } } -// buildStreamingTypeSpec creates a TypeSpec for streaming types -func (g *Generator) buildStreamingTypeSpec(typeStr string, _ bool, isBidirectional bool, info MethodInfo) *TypeSpec { - // For WebSocket bidirectional methods, include mapping fields only when explicitly requested via idmap - if isBidirectional { - switch typeStr { - case TypeString: - if info.Modifier == ModifierIDMap { - return &TypeSpec{ - Kind: "object", - NeedsID: true, - Fields: []FieldSpec{ - {Position: 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: false, Description: "Business-level ID"}, - {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: false, Description: "Mapped JSON-RPC envelope ID"}, - {Position: 3, Name: "value", GoName: "Value", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true, Description: "String value"}, - }, - } - } - // No id mapping: only value field - return &TypeSpec{Kind: "object", Fields: []FieldSpec{{Position: 1, Name: "value", GoName: "Value", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true}}} - case TypeArray: - if info.Modifier == ModifierIDMap { - return &TypeSpec{Kind: "object", NeedsID: true, Fields: []FieldSpec{ - {Position: 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 3, Name: "items", GoName: "Items", Type: &TypeSpec{Kind: "array", ArrayElem: &TypeSpec{Kind: "primitive", Primitive: "String"}}, Required: true}, - }} - } - return &TypeSpec{Kind: "object", Fields: []FieldSpec{{Position: 1, Name: "items", GoName: "Items", Type: &TypeSpec{Kind: "array", ArrayElem: &TypeSpec{Kind: "primitive", Primitive: "String"}}, Required: true}}} - case TypeObject: - if info.Modifier == ModifierIDMap { - return &TypeSpec{Kind: "object", NeedsID: true, Fields: []FieldSpec{ - {Position: 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 3, Name: "field1", GoName: "Field1", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true}, - {Position: 4, Name: "field2", GoName: "Field2", Type: &TypeSpec{Kind: "primitive", Primitive: "Int"}, Required: true}, - {Position: 5, Name: "field3", GoName: "Field3", Type: &TypeSpec{Kind: "primitive", Primitive: "Boolean"}, Required: true}, - }} - } - return &TypeSpec{Kind: "object", Fields: []FieldSpec{ - {Position: 1, Name: "field1", GoName: "Field1", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true}, - {Position: 2, Name: "field2", GoName: "Field2", Type: &TypeSpec{Kind: "primitive", Primitive: "Int"}, Required: true}, - {Position: 3, Name: "field3", GoName: "Field3", Type: &TypeSpec{Kind: "primitive", Primitive: "Boolean"}, Required: true}, - }} - default: - if info.Modifier == ModifierIDMap { - return &TypeSpec{Kind: "object", NeedsID: true, Fields: []FieldSpec{ - {Position: 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 3, Name: "data", GoName: "Data", Type: &TypeSpec{Kind: "primitive", Primitive: "Any"}, Required: true}, - }} - } - return &TypeSpec{Kind: "object", Fields: []FieldSpec{{Position: 1, Name: "data", GoName: "Data", Type: &TypeSpec{Kind: "primitive", Primitive: "Any"}, Required: true}}} - } - } - - // For non-bidirectional streaming, wrap primitives in objects +// buildStreamingTypeSpec creates the object sent in each SSE notification. +func (g *Generator) buildStreamingTypeSpec(typeStr string) *TypeSpec { + // Wrap primitives so each notification has a named JSON field. spec := g.buildTypeSpec(typeStr, "") if spec.Kind == "primitive" { return &TypeSpec{Kind: "object", Fields: []FieldSpec{{Position: 1, Name: "value", GoName: "Value", Type: spec, Required: true, Description: fmt.Sprintf("%s value", spec.Primitive)}}} @@ -341,20 +270,18 @@ func (g *Generator) buildImplementationData(design *DesignData) *ImplementationD // buildMethodImplData creates implementation data for a method func (g *Generator) buildMethodImplData(method *MethodData, serviceName string) *MethodImplData { - data := &MethodImplData{MethodData: method, ServicePackage: serviceName, HasPayload: method.Payload != nil || method.StreamingPayload != nil, HasResult: method.Result != nil} + data := &MethodImplData{MethodData: method, ServicePackage: serviceName, HasPayload: method.Payload != nil, HasResult: method.Result != nil} if method.Payload != nil { if method.Payload.Kind == "primitive" { data.PayloadRef = strings.ToLower(method.Payload.Primitive) } else { data.PayloadRef = fmt.Sprintf("*%s.%sPayload", serviceName, method.GoName) } - } else if method.StreamingPayload != nil && data.StreamKind == "bidirectional" { - data.PayloadRef = fmt.Sprintf("*%s.%sPayload", serviceName, method.GoName) } if method.Result != nil { - if method.Result.Kind == "primitive" { + if !method.IsStreaming && method.Result.Kind == "primitive" { data.ResultRef = strings.ToLower(method.Result.Primitive) - } else { + } else if !method.IsStreaming { data.ResultRef = fmt.Sprintf("*%s.%sResult", serviceName, method.GoName) } } @@ -368,14 +295,6 @@ func (g *Generator) buildMethodImplData(method *MethodData, serviceName string) func (g *Generator) templateFuncs() template.FuncMap { return template.FuncMap{ "goify": goify, - "hasStreamingMethod": func(methods []*MethodImplData) bool { - for _, m := range methods { - if m.IsStreaming { - return true - } - } - return false - }, "collectRequired": func(fields []FieldSpec) []string { var required []string for _, f := range fields { @@ -393,9 +312,6 @@ func (g *Generator) getServiceName(info MethodInfo) string { if info.IsSSE() { return "testsse" } - if info.IsWebSocket() { - return "testws" - } return "test" } @@ -404,8 +320,6 @@ func (g *Generator) getJSONRPCPath(serviceName string) string { switch serviceName { case "testsse": return "/jsonrpc/sse" - case "testws": - return "/jsonrpc/ws" default: return "/jsonrpc" } @@ -487,9 +401,6 @@ func (g *Generator) parseServiceMethodPairs(serviceName string) []methodPair { continue } name := fld.Names[0].Name - if name == "HandleStream" { - continue - } goNames = append(goNames, name) } return false @@ -540,7 +451,6 @@ func (g *Generator) filesImpl(impl *ImplementationData) []*codegen.File { {Path: "time"}, {Path: "strings"}, {Path: "sort"}, - {Path: "io"}, {Name: "goa", Path: "goa.design/goa/v3/pkg"}, {Name: service.ServicePackage, Path: fmt.Sprintf("testservice/gen/%s", service.ServicePackage)}, } @@ -548,7 +458,7 @@ func (g *Generator) filesImpl(impl *ImplementationData) []*codegen.File { codegen.Header(fmt.Sprintf("%s service implementation", service.Title), "testservice", imports), { Name: "service-impl", - Source: generatorTemplates.Read("impl/service", "method_signature", "error", "echo", "transform", "generate", "streaming_sse", "streaming_websocket", "notify", "validate"), + Source: generatorTemplates.Read("impl/service", "method_signature", "error", "echo", "transform", "generate", "streaming_sse", "notify", "validate"), FuncMap: g.templateFuncs(), Data: service, }, diff --git a/jsonrpc/integration_tests/framework/options.go b/jsonrpc/integration_tests/framework/options.go index 8717a58684..1bc6174e00 100644 --- a/jsonrpc/integration_tests/framework/options.go +++ b/jsonrpc/integration_tests/framework/options.go @@ -113,15 +113,8 @@ func ApplyOptions(config *RunnerConfig, opts ...RunnerOption) { type executorOption func(*executorConfig) type executorConfig struct { - WebSocketTimeout time.Duration - Debug bool - WorkDir string -} - -func withWebSocketTimeout(d time.Duration) executorOption { - return func(c *executorConfig) { - c.WebSocketTimeout = d - } + Debug bool + WorkDir string } func withExecutorDebug(debug bool) executorOption { diff --git a/jsonrpc/integration_tests/framework/runner.go b/jsonrpc/integration_tests/framework/runner.go index 378d7e8e2a..35c3b70905 100644 --- a/jsonrpc/integration_tests/framework/runner.go +++ b/jsonrpc/integration_tests/framework/runner.go @@ -254,11 +254,8 @@ func (r *Runner) runScenario(t *testing.T, scenario Scenario) { t.Fatal("No server URL configured") } - // Create executor with timeout from settings + // Create the executor for this generated service. opts := []executorOption{} - if r.config.Settings.Timeout > 0 { - opts = append(opts, withWebSocketTimeout(r.config.Settings.Timeout)) - } opts = append(opts, withWorkDir(r.testDir)) // Enable debug if requested diff --git a/jsonrpc/integration_tests/framework/templates/dsl/method.go.tpl b/jsonrpc/integration_tests/framework/templates/dsl/method.go.tpl index 1060b26148..c427c2350d 100644 --- a/jsonrpc/integration_tests/framework/templates/dsl/method.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/dsl/method.go.tpl @@ -4,10 +4,7 @@ Method("{{ .Name }}", func() { {{- if .Payload }} Payload({{ template "inline_type" .Payload }}) {{- end }} -{{- if .StreamingPayload }} - StreamingPayload({{ template "inline_type" .StreamingPayload }}) -{{- end }} -{{- if and .Result .IsStreaming (or (eq .StreamKind "result") (eq .StreamKind "bidirectional")) }} +{{- if and .Result .IsStreaming }} StreamingResult({{ template "inline_type" .Result }}) {{- else if and .Result (not .IsNotification) }} Result({{ template "inline_type" .Result }}) @@ -59,4 +56,4 @@ func() { {{- else -}} Any {{- end -}} -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/dsl/type.go.tpl b/jsonrpc/integration_tests/framework/templates/dsl/type.go.tpl index e9aa2a019d..97b9bc4933 100644 --- a/jsonrpc/integration_tests/framework/templates/dsl/type.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/dsl/type.go.tpl @@ -19,10 +19,6 @@ func() { {{- if $required }} Required({{ range $i, $f := $required }}{{ if $i }}, {{ end }}"{{ $f }}"{{ end }}) {{- end }} - {{- if .NeedsID }} - // Accept JSON-RPC ID in payload for WS, optional; transport-level ID is handled separately - Field(99, "id", String) - {{- end }} } {{- else if eq .Kind "map" -}} func() { @@ -61,4 +57,4 @@ func() { {{- define "map_type" -}} MapOf({{ if .MapKey }}{{ template "type" .MapKey }}{{ else }}String{{ end }}, {{ if .MapValue }}{{ template "type" .MapValue }}{{ else }}Any{{ end }}) -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/impl/service.go.tpl b/jsonrpc/integration_tests/framework/templates/impl/service.go.tpl index 6209f0c9e1..2df0c59f5f 100644 --- a/jsonrpc/integration_tests/framework/templates/impl/service.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/impl/service.go.tpl @@ -1,44 +1,12 @@ // {{ .ServicePackage }}srvc implements the {{ .ServicePackage }} service. type {{ .ServicePackage }}srvc struct { logger *log.Logger -{{- range .Methods }} -{{- if and (eq .Info.Action "collect") (eq .Info.Type "array") (eq .Transport "ws") }} - // State for accumulating items in {{ .Name }} - collectedItems []string -{{- end }} -{{- end }} } // New{{ .Title }} returns the {{ .ServicePackage }} service implementation. func New{{ .Title }}() {{ .ServicePackage }}.Service { return &{{ .ServicePackage }}srvc{} } -{{- if eq .Name "testws" }} - -// HandleStream handles the JSON-RPC WebSocket streaming connection -func (s *{{ .ServicePackage }}srvc) HandleStream(ctx context.Context, stream {{ .ServicePackage }}.Stream) error { - // For testing purposes, we only send broadcasts when explicitly called through the broadcast method - // In a real application, you might send broadcasts based on external events or timers - - // Ensure the stream is closed on exit - defer func() { - _ = stream.Close() - }() - - // Loop to handle incoming requests - for { - // Recv reads and dispatches the next request - if err := stream.Recv(ctx); err != nil { - // Log the error type and value for diagnostics - log.Printf("HandleStream Recv error: %T %v", err, err) - if err == io.EOF { - return nil - } - return err - } - } -} -{{- end }} {{- range .Methods }} {{- if or (not .IsNotification) (and .IsNotification .IsStreaming) }} @@ -46,11 +14,7 @@ func (s *{{ .ServicePackage }}srvc) HandleStream(ctx context.Context, stream {{ {{ template "partial_method_signature" . }} { log.Printf("{{ .GoName }} called") {{- if .IsStreaming }} -{{- if .IsSSE }} {{ template "partial_streaming_sse" . }} -{{- else if .IsWebSocket }} -{{ template "partial_streaming_websocket" . }} -{{- end }} {{- else if .ReturnsError }} {{ template "partial_error" . }} {{- else }} @@ -71,4 +35,4 @@ func (s *{{ .ServicePackage }}srvc) HandleStream(ctx context.Context, stream {{ {{- end }} } {{- end }} -{{- end }} \ No newline at end of file +{{- end }} diff --git a/jsonrpc/integration_tests/framework/templates/partial/method.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/method.go.tpl index aa78a2ac28..cff6953dd8 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/method.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/method.go.tpl @@ -2,16 +2,9 @@ Method("{{ .Name }}", func() { Description("{{ .Description }}") {{- if .Payload }} Payload({{ template "partial_type" .Payload }}) -{{- else if and .StreamingPayload (eq .StreamKind "bidirectional") }} - Payload(func() { - Description("Initial payload") - }) -{{- end }} -{{- if .StreamingPayload }} - StreamingPayload({{ template "partial_type" .StreamingPayload }}) {{- end }} {{- if .Result }} -{{- if or (eq .StreamKind "result") (eq .StreamKind "bidirectional") }} +{{- if .IsStreaming }} StreamingResult({{ template "partial_type" .Result }}) {{- else if not .IsNotification }} Result({{ template "partial_type" .Result }}) @@ -25,4 +18,4 @@ Method("{{ .Name }}", func() { ServerSentEvents() {{- end }} }) -}) \ No newline at end of file +}) diff --git a/jsonrpc/integration_tests/framework/templates/partial/method_signature.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/method_signature.go.tpl index f2fd7f1acf..4061f4ea87 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/method_signature.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/method_signature.go.tpl @@ -1,18 +1,6 @@ {{- /* Template for generating method signature */ -}} {{- if .IsStreaming -}} - {{- if .IsSSE -}} func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}, stream {{ $.ServicePackage }}.{{ .StreamInterface }}) error - {{- else if .IsWebSocket -}} - {{- if .IsBidirectional -}} -func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}, stream {{ $.ServicePackage }}.{{ .StreamInterface }}) error - {{- else if eq .StreamKind "payload" -}} -func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context, stream {{ $.ServicePackage }}.{{ .StreamInterface }}) error - {{- else -}} -func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}, stream {{ $.ServicePackage }}.{{ .StreamInterface }}) error - {{- end -}} - {{- else -}} -func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}) {{ if .HasResult }}({{ .ResultRef }}, error){{ else }}error{{ end }} - {{- end -}} {{- else -}} func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}) {{ if .HasResult }}({{ .ResultRef }}, error){{ else }}error{{ end }} -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/partial/streaming_sse.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/streaming_sse.go.tpl index 056d33b58f..43eb48e367 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/streaming_sse.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/streaming_sse.go.tpl @@ -9,7 +9,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: p, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "array" -}} @@ -18,7 +18,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{item}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -29,7 +29,7 @@ Field2: p.Field2, Field3: p.Field3, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "map" -}} @@ -37,7 +37,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: p.Data, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end -}} @@ -54,7 +54,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: strings.ToUpper(p), } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "array" -}} @@ -66,7 +66,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: reversed, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "object" -}} @@ -76,7 +76,7 @@ Field2: p.Field2 * 2, Field3: !p.Field3, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "map" -}} @@ -88,7 +88,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: transformed, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end -}} @@ -105,7 +105,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: fmt.Sprintf("generated-%d", i), } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -115,7 +115,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{fmt.Sprintf("item-%d", i)}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -127,7 +127,7 @@ Field2: i * 10, Field3: i%2 == 0, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -140,7 +140,7 @@ "status": fmt.Sprintf("step-%d", i), }, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -165,7 +165,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: fmt.Sprintf("Stream %d of %d", i, count), } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } // Small delay to simulate streaming @@ -179,7 +179,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{"empty"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } else { @@ -188,7 +188,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{fmt.Sprintf("Processing: %s", item)}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } // Small delay between items @@ -202,7 +202,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{"stream-1", "stream-2", "stream-3"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end }} @@ -229,7 +229,7 @@ Field2: i, Field3: i == count, // true for last item } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } time.Sleep(10 * time.Millisecond) @@ -242,7 +242,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: map[string]any{"status": "empty"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } else { @@ -262,7 +262,7 @@ "value": v, }, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } time.Sleep(10 * time.Millisecond) @@ -277,7 +277,7 @@ "status": fmt.Sprintf("step-%d", i), }, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } time.Sleep(10 * time.Millisecond) @@ -293,14 +293,14 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: p, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "array" -}} result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: p.Items, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "object" -}} @@ -309,14 +309,14 @@ Field2: p.Field2, Field3: p.Field3, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "map" -}} result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: p.Data, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end -}} @@ -326,14 +326,14 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: "default", } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "array" -}} result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{"default"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "object" -}} @@ -342,41 +342,23 @@ Field2: 0, Field3: false, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "map" -}} result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: map[string]any{"status": "default"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end -}} {{- end -}} -{{- end -}} +{{- end }} -{{- /* Handle modifiers for protocol-level behavior */ -}} -{{- if eq .Info.Modifier "final" -}} - // Send final response with ID using SendAndClose - finalResult := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - {{- if eq .Info.Type "string" -}} - Value: "Final response", - {{- else if eq .Info.Type "array" -}} - Items: []string{"completed"}, - {{- else if eq .Info.Type "object" -}} - Field1: "completed", - Field2: 100, - Field3: true, - {{- else if eq .Info.Type "map" -}} - Data: map[string]any{"status": "completed", "final": true}, - {{- end -}} - } - return stream.SendAndClose(ctx, finalResult) -{{- else if eq .Info.Modifier "error" -}} - // Return an error after streaming +{{ if eq .Info.Modifier "error" -}} + // Return an error after streaming. return &goa.ServiceError{Message: "Streaming error occurred"} -{{- else -}} - // No final response for pure notifications +{{ else -}} return nil -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/partial/streaming_websocket.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/streaming_websocket.go.tpl deleted file mode 100644 index dc1ffcf307..0000000000 --- a/jsonrpc/integration_tests/framework/templates/partial/streaming_websocket.go.tpl +++ /dev/null @@ -1,522 +0,0 @@ -{{- /* Template for WebSocket streaming method implementation */ -}} -{{- /* WebSocket behavior is determined by the action, similar to SSE */ -}} - -{{- /* Handle validation first if validate modifier is set */ -}} -{{- if eq .Info.Modifier "validate" }} - {{- if eq .Info.Type "object" }} - if p != nil && (p.Field1 == "" || p.Field2 < 0) { - validationErr := &goa.ServiceError{ - Name: "validation_error", - Message: "validation error", - } - if err := stream.SendError(ctx, validationErr); err != nil { - return err - } - return nil - } - {{- else if eq .Info.Type "string" }} - if p != nil && p.Value == "" { - validationErr := &goa.ServiceError{ - Name: "validation_error", - Message: "validation error", - } - if err := stream.SendError(ctx, validationErr); err != nil { - return err - } - return nil - } - {{- end }} -{{- end }} - -{{- /* For echo with error modifier, return error immediately */ -}} -{{- if and (eq .Info.Action "echo") (eq .Info.Modifier "error") }} - // For echo methods with error modifier, always send error response - testErr := &goa.ServiceError{ - Name: "test_error", - Message: "Invalid params", - } - if err := stream.SendError(ctx, testErr); err != nil { - return err - } - return nil -{{- else if eq .Info.Action "echo" }} - {{- /* Echo action: Return the payload exactly as received */ -}} - {{- if .IsBidirectional }} - {{- if eq .Info.Type "string" }} - // Echo back the received payload - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: p.Value, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "array" }} - // Echo back the received array - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: p.Items, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "object" }} - // Echo back the received object - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: p.Field1, - Field2: p.Field2, - Field3: p.Field3, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "map" }} - // Echo back the received map - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Data: p.Data, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- end }} - return nil - {{- else }} - // Non-bidirectional echo - shouldn't happen for WebSocket - return fmt.Errorf("echo action requires bidirectional streaming") - {{- end }} - -{{- else if eq .Info.Action "transform" }} - {{- /* Transform action: Apply transformations to the payload */ -}} - {{- if .IsBidirectional }} - {{- if eq .Info.Type "string" }} - // Transform and return: uppercase - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: strings.ToUpper(p.Value), - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "array" }} - // Transform and return: reverse the array - if p != nil { - reversed := make([]string, len(p.Items)) - for i, item := range p.Items { - reversed[len(p.Items)-1-i] = item - } - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: reversed, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "object" }} - // Transform and return: uppercase field1, double field2, negate field3 - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: strings.ToUpper(p.Field1), - Field2: p.Field2 * 2, - Field3: !p.Field3, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "map" }} - // Transform and return: prefix all keys with "transformed_" - if p != nil && p.Data != nil { - transformed := make(map[string]any) - if data, ok := p.Data.(map[string]any); ok { - for k, v := range data { - transformed["transformed_"+k] = v - } - } - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Data: transformed, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- end }} - return nil - {{- else }} - return fmt.Errorf("transform action requires bidirectional streaming") - {{- end }} - -{{- else if eq .Info.Action "generate" }} - {{- /* Generate action: Return fixed values, ignoring payload */ -}} - {{- if .IsBidirectional }} - {{- if eq .Info.Type "string" }} - // Generate and return fixed string - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: "generated-string", - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "array" }} - // Generate and return fixed array - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: []string{"item1", "item2", "item3"}, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "object" }} - // Generate and return fixed object - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: "generated-value1", - Field2: 42, - Field3: true, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "map" }} - // Generate and return fixed map - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Data: map[string]any{ - "generated": true, - "count": 3, - "status": "ok", - }, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- end }} - return nil - {{- else }} - // Server-initiated generation (no client request) - {{- if eq .Info.Type "string" }} - for i := 1; i <= 3; i++ { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: fmt.Sprintf("generated-%d", i), - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - return nil - {{- else }} - // Generate default values - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{} - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - return nil - {{- end }} - {{- end }} - -{{- else if eq .Info.Action "stream" }} - {{- /* Stream action: Send a series of messages based on payload */ -}} - {{- if .IsBidirectional }} - {{- if eq .Info.Type "string" }} - // Stream notifications based on string payload - if p != nil { - count := 3 // default - if p.Value != "" { - count = len(p.Value) - if count > 10 { - count = 10 - } - } - - // For error modifier, send fewer notifications - streamCount := count - {{- if eq .Info.Modifier "error" }} - if streamCount > 2 { - streamCount = 2 - } - {{- end }} - - // Send notifications without ID - for i := 1; i <= streamCount; i++ { - notification := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: fmt.Sprintf("Stream %d of %d", i, count), - } - if err := stream.SendNotification(ctx, notification); err != nil { - return err - } - } - {{- if ne .Info.Modifier "error" }} - // Send final response with ID - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: "completed", - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - {{- end }} - } - {{- else if eq .Info.Type "array" }} - // Stream notifications for each array item - if p != nil { - // Send notification for each item - for _, item := range p.Items { - notification := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: []string{fmt.Sprintf("Processing: %s", item)}, - } - if err := stream.SendNotification(ctx, notification); err != nil { - return err - } - } - {{- if ne .Info.Modifier "error" }} - // Send final response with ID - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: []string{"completed"}, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - {{- end }} - } - {{- else if eq .Info.Type "object" }} - // Stream notifications based on field2 count - if p != nil { - count := p.Field2 - if count <= 0 { - count = 3 - } - if count > 10 { - count = 10 - } - // Send notifications without ID - for i := 1; i <= count; i++ { - notification := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: fmt.Sprintf("%s-%d", p.Field1, i), - Field2: i, - Field3: i == count, - } - if err := stream.SendNotification(ctx, notification); err != nil { - return err - } - } - {{- if ne .Info.Modifier "error" }} - // Send final response with ID - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: "completed", - Field2: 100, - Field3: true, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - {{- end }} - } - {{- else if eq .Info.Type "map" }} - // Stream notifications for each key-value pair - if p != nil && p.Data != nil { - // Send notification for each pair - if data, ok := p.Data.(map[string]any); ok { - // Sort keys for deterministic ordering - keys := make([]string, 0, len(data)) - for k := range data { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, k := range keys { - v := data[k] - notification := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Data: map[string]any{ - "key": k, - "value": v, - }, - } - if err := stream.SendNotification(ctx, notification); err != nil { - return err - } - } - } - {{- if ne .Info.Modifier "error" }} - // Send final response with ID - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Data: map[string]any{ - "status": "completed", - }, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - {{- end }} - } - {{- end }} - {{- if ne .Info.Modifier "error" }} - return nil - {{- end }} - {{- else }} - // Server-side streaming without client payload - return fmt.Errorf("stream action requires bidirectional streaming for WebSocket") - {{- end }} - -{{- else if eq .Info.Action "collect" }} - {{- /* Collect action: Accumulate client messages */ -}} - {{- if eq .Info.Type "array" }} - // For JSON-RPC WebSocket, each request comes as a separate call to this method - // We accumulate items across requests using service-level state - if p != nil && p.Items != nil { - s.collectedItems = append(s.collectedItems, p.Items...) - } - - // Return the accumulated items - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: s.collectedItems, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - return nil - {{- else }} - // Collect only supports array type currently - return fmt.Errorf("collect action only supports array type") - {{- end }} - -{{- else if eq .Info.Action "broadcast" }} - {{- /* Broadcast action: Server-initiated messages */ -}} - {{- if .IsBidirectional }} - // Broadcast method implementation - bidirectional streaming - // When called (e.g., with "subscribe"), send test broadcast notifications - {{- if eq .Info.Type "string" }} - // Send test broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{ - Value: fmt.Sprintf("Server announcement %d", i), - } - if err := stream.SendNotification(ctx, result); err != nil { - return err - } - } - return nil - {{- else if eq .Info.Type "array" }} - // Send test array broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{ - Items: []string{fmt.Sprintf("broadcast-%d", i)}, - } - if err := stream.SendNotification(ctx, result); err != nil { - return err - } - } - return nil - {{- else if eq .Info.Type "object" }} - // Send test object broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{ - Field1: fmt.Sprintf("broadcast-%d", i), - Field2: i, - Field3: i%2 == 0, - } - if err := stream.SendNotification(ctx, result); err != nil { - return err - } - } - return nil - {{- else if eq .Info.Type "map" }} - // Send test map broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{ - Data: map[string]any{ - "broadcast": i, - "timestamp": time.Now().Unix(), - }, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - return nil - {{- else }} - // Send default broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{} - if err := stream.SendNotification(ctx, result); err != nil { - return err - } - } - return nil - {{- end }} - {{- else }} - // Broadcast action requires bidirectional streaming - return fmt.Errorf("broadcast action requires bidirectional streaming") - {{- end }} - -{{- else }} - {{- /* Default: echo behavior for unknown actions */ -}} - // Default WebSocket implementation for JSON-RPC - // Each request comes as a separate call - if p != nil { - // Echo payload back - {{- if eq .Info.Type "string" }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Value: p.Value, - } - {{- else if eq .Info.Type "array" }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Items: p.Items, - } - {{- else if eq .Info.Type "object" }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Field1: p.Field1, - Field2: p.Field2, - Field3: p.Field3, - } - {{- else if eq .Info.Type "map" }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Data: p.Data, - } - {{- else }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{} - {{- end }} - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } -{{- end }} - -{{- /* Handle remaining modifiers */ -}} -{{- if eq .Info.Modifier "error" }} - {{- /* For stream action with error, send error after streaming */ -}} - {{- if eq .Info.Action "stream" }} - // For stream methods with error modifier, send error after streaming - testErr := &goa.ServiceError{ - Name: "test_error", - Message: "Streaming error occurred", - } - if err := stream.SendError(ctx, testErr); err != nil { - return err - } - return nil - {{- else }} - // Other actions with error modifier should have been handled above - return nil - {{- end }} -{{- else if eq .Info.Modifier "notify" }} - // Notification - no response sent (already handled above) - return nil -{{- else }} - // Normal completion - return nil -{{- end }} \ No newline at end of file diff --git a/jsonrpc/integration_tests/framework/templates/partial/type.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/type.go.tpl index d876481209..d5f3c9a925 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/type.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/type.go.tpl @@ -18,9 +18,6 @@ func() { {{- if $required }} Required({{ range $i, $f := $required }}{{ if $i }}, {{ end }}"{{ $f }}"{{ end }}) {{- end }} -{{- if .NeedsID }} - ID("id") -{{- end }} } {{- else if eq .Kind "map" -}} func() { @@ -29,4 +26,4 @@ func() { } {{- else -}} Any -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/types.go b/jsonrpc/integration_tests/framework/types.go index ca0a02c859..bd3772f49e 100644 --- a/jsonrpc/integration_tests/framework/types.go +++ b/jsonrpc/integration_tests/framework/types.go @@ -6,13 +6,6 @@ import ( "time" ) -// Sequence action types for streaming scenarios -const ( - SequenceActionSend = "send" - SequenceActionReceive = "receive" - SequenceActionClose = "close" -) - // Scenario represents a test scenario loaded from YAML type Scenario struct { Name string `yaml:"name"` @@ -79,14 +72,14 @@ type Settings struct { type MethodInfo struct { Action string // echo, transform, generate, etc. Type string // string, array, object, etc. - Modifier string // notify, error, validate, final - Transport string // sse, ws (extracted from method suffix) + Modifier string // notify, error, validate, or idmap + Transport string // sse when the method sends server-sent events GRPC bool // whether to emit GRPC endpoint (method name starts with grpc_) } // ParseMethod parses a method name into its components. -// Format: action_type[_modifier][_transport] -// Examples: echo_string, stream_object_final_sse, broadcast_string_ws +// Format: action_type[_modifier][_sse] +// Examples: echo_string, stream_object_sse // Returns error if the method name is invalid. func ParseMethod(method string) (MethodInfo, error) { // Check for grpc_ prefix convention @@ -98,7 +91,7 @@ func ParseMethod(method string) (MethodInfo, error) { parts := strings.Split(method, "_") if len(parts) < 2 { - return MethodInfo{}, fmt.Errorf("invalid method name %q: must have format action_type[_modifier][_transport]", method) + return MethodInfo{}, fmt.Errorf("invalid method name %q: must have format action_type[_modifier][_sse]", method) } info := MethodInfo{ @@ -110,7 +103,7 @@ func ParseMethod(method string) (MethodInfo, error) { // Check if last part is a transport if len(parts) > 2 { lastPart := parts[len(parts)-1] - if lastPart == "sse" || lastPart == "ws" { + if lastPart == "sse" { info.Transport = lastPart parts = parts[:len(parts)-1] // Remove transport from parts } @@ -119,7 +112,7 @@ func ParseMethod(method string) (MethodInfo, error) { // Validate action validActions := map[string]bool{ ActionEcho: true, ActionTransform: true, ActionGenerate: true, - ActionStream: true, ActionCollect: true, ActionBroadcast: true, + ActionStream: true, } if !validActions[info.Action] { return MethodInfo{}, fmt.Errorf("invalid action %q in method %q: must be one of: %s", @@ -141,13 +134,16 @@ func ParseMethod(method string) (MethodInfo, error) { info.Modifier = parts[2] // Validate modifier validModifiers := map[string]bool{ - ModifierNotify: true, ModifierError: true, ModifierValidate: true, ModifierFinal: true, ModifierIDMap: true, + ModifierNotify: true, ModifierError: true, ModifierValidate: true, ModifierIDMap: true, } if !validModifiers[info.Modifier] { return MethodInfo{}, fmt.Errorf("invalid modifier %q in method %q: must be one of: %s", info.Modifier, method, strings.Join(getMapKeys(validModifiers), ", ")) } } + if info.Action == ActionStream && !info.IsSSE() { + return MethodInfo{}, fmt.Errorf("streaming method %q must end with _sse", method) + } return info, nil } @@ -178,41 +174,9 @@ func (info MethodInfo) IsSSE() bool { return info.Transport == "sse" } -// IsWebSocket returns true if this method uses WebSocket transport -func (info MethodInfo) IsWebSocket() bool { - return info.Transport == "ws" -} - // IsStreaming returns true if this method involves streaming func (info MethodInfo) IsStreaming() bool { - return info.IsSSE() || info.IsWebSocket() || info.Action == ActionStream || info.Action == ActionCollect || info.Action == ActionBroadcast -} - -// HasStreamingResult returns true if this method streams results -func (info MethodInfo) HasStreamingResult() bool { - if info.IsSSE() { - return true // SSE always streams results - } - if info.IsWebSocket() { - // WebSocket methods can stream results based on action - return info.Action == ActionStream || info.Action == ActionBroadcast || - info.Action == ActionEcho || info.Action == ActionTransform || info.Action == ActionGenerate || - info.Action == ActionCollect - } - return false -} - -// HasStreamingPayload returns true if this method streams payload -func (info MethodInfo) HasStreamingPayload() bool { - if info.IsSSE() { - return false // SSE doesn't support streaming payload - } - if info.IsWebSocket() { - // All WebSocket methods have streaming payload for bidirectional support - // This allows them to receive requests and send responses/notifications - return true - } - return false + return info.IsSSE() } // GetMethod returns the effective JSON-RPC method name to use on the wire. diff --git a/jsonrpc/integration_tests/go.mod b/jsonrpc/integration_tests/go.mod index 23bbfbe3d3..a433fd0519 100644 --- a/jsonrpc/integration_tests/go.mod +++ b/jsonrpc/integration_tests/go.mod @@ -3,7 +3,6 @@ module goa.design/goa/v3/jsonrpc/integration_tests go 1.25.0 require ( - github.com/gorilla/websocket v1.5.3 github.com/stretchr/testify v1.12.1 goa.design/goa/v3 v3.0.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/jsonrpc/integration_tests/go.sum b/jsonrpc/integration_tests/go.sum index 369b4fb313..890f3eb085 100644 --- a/jsonrpc/integration_tests/go.sum +++ b/jsonrpc/integration_tests/go.sum @@ -7,8 +7,6 @@ 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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= diff --git a/jsonrpc/integration_tests/harness/cli_client.go b/jsonrpc/integration_tests/harness/cli_client.go index 6a46dd275f..e4aec27272 100644 --- a/jsonrpc/integration_tests/harness/cli_client.go +++ b/jsonrpc/integration_tests/harness/cli_client.go @@ -49,8 +49,7 @@ func NewCLIClient(workDir, serverURL string) (*CLIClient, error) { func (c *CLIClient) CallMethod(ctx context.Context, service, method string, payload any) (json.RawMessage, error) { // Convert method name from snake_case to kebab-case for CLI cliMethod := strings.ReplaceAll(method, "_", "-") - - + // Build command arguments - use go run to execute the CLI // URL must come before service and method for proper flag parsing args := []string{ @@ -85,7 +84,7 @@ func (c *CLIClient) CallMethod(ctx context.Context, service, method string, payl } } // If payload is nil, don't add any body argument - let the CLI handle it - + // Create command with all args cmd = exec.CommandContext(ctx, "go", args...) cmd.Dir = c.cliPath @@ -111,7 +110,7 @@ func (c *CLIClient) CallMethod(ctx context.Context, service, method string, payl // Parse verbose output from stderr to get the raw JSON-RPC response verboseOutput := stderr.String() lines := strings.Split(verboseOutput, "\n") - + // Find the JSON-RPC response - it's the last line starting with { for i := len(lines) - 1; i >= 0; i-- { line := strings.TrimSpace(lines[i]) @@ -123,7 +122,7 @@ func (c *CLIClient) CallMethod(ctx context.Context, service, method string, payl Message string `json:"message"` } `json:"error"` } - + if err := json.Unmarshal([]byte(line), &resp); err == nil && resp.Result != nil { return resp.Result, nil } @@ -165,17 +164,16 @@ func (c *CLIClient) CallJSONRPC(ctx context.Context, request map[string]any) (js // CanHandle returns true if the CLI can handle this method func (c *CLIClient) CanHandle(method string, params any) bool { - // CLI can handle HTTP methods but not streaming - // Check if it's a streaming method by looking for WebSocket or SSE in the method name - if strings.Contains(method, "_ws") || strings.Contains(method, "_sse") { + // CLI can handle unary HTTP methods but not SSE streams. + if strings.Contains(method, "_sse") { return false } - + // CLI doesn't handle notification methods (no response expected) if strings.Contains(method, "_notify") { return false } - + // CLI can handle methods with payloads return true } diff --git a/jsonrpc/integration_tests/harness/client.go b/jsonrpc/integration_tests/harness/client.go index dfd027642b..80e0beb501 100644 --- a/jsonrpc/integration_tests/harness/client.go +++ b/jsonrpc/integration_tests/harness/client.go @@ -11,8 +11,6 @@ import ( "net/url" "strings" "time" - - "github.com/gorilla/websocket" ) // JSONRPCRequest represents a JSON-RPC 2.0 request @@ -21,7 +19,6 @@ type JSONRPCRequest struct { Method string `json:"method"` Params any `json:"params,omitempty"` ID any `json:"id,omitempty"` - HasID bool `json:"-"` // true if the id key must be included even if null } // Default values @@ -29,7 +26,6 @@ const ( DefaultHTTPTimeout = 10 * time.Second DefaultJSONRPCPath = "/jsonrpc" DefaultSSEPath = "/jsonrpc/sse" - DefaultWSPath = "/jsonrpc/ws" ) // ClientConfig holds client configuration @@ -40,14 +36,10 @@ type ClientConfig struct { JSONRPCPath string // SSEPath is the path for SSE endpoint SSEPath string - // WSPath is the path for WebSocket endpoint - WSPath string // Headers are additional headers to send with requests Headers map[string]string // HTTPClient allows using a custom HTTP client HTTPClient *http.Client - // WSDialer allows using a custom WebSocket dialer - WSDialer *websocket.Dialer } // DefaultConfig returns default client configuration @@ -56,7 +48,6 @@ func DefaultConfig() *ClientConfig { HTTPTimeout: DefaultHTTPTimeout, JSONRPCPath: DefaultJSONRPCPath, SSEPath: DefaultSSEPath, - WSPath: DefaultWSPath, Headers: make(map[string]string), } } @@ -66,8 +57,6 @@ type Client struct { baseURL *url.URL config *ClientConfig httpClient *http.Client - wsDialer *websocket.Dialer - wsConn *websocket.Conn } // NewClient creates a new JSON-RPC client @@ -89,25 +78,10 @@ func NewClient(baseURL string, config *ClientConfig) (*Client, error) { } } - // Create WebSocket dialer if not provided - wsDialer := config.WSDialer - if wsDialer == nil { - // Do not use proxies from environment variables for integration tests. - // A developer shell may have HTTP(S)_PROXY set, which breaks localhost - // WebSocket upgrades and causes "websocket: bad handshake" failures. - // - // Gorilla uses ProxyFromEnvironment in websocket.DefaultDialer; setting - // Proxy to nil disables proxy use entirely. - defaultDialer := *websocket.DefaultDialer - defaultDialer.Proxy = nil - wsDialer = &defaultDialer - } - return &Client{ baseURL: u, config: config, httpClient: httpClient, - wsDialer: wsDialer, }, nil } @@ -319,165 +293,3 @@ func (c *Client) parseSSEEvents(r io.Reader) ([]json.RawMessage, error) { return events, scanner.Err() } - -// ConnectWebSocket establishes a WebSocket connection -func (c *Client) ConnectWebSocket(ctx context.Context) error { - // Build WebSocket URL - wsURL := *c.baseURL - wsURL.Path = c.config.WSPath - - // Convert scheme - switch wsURL.Scheme { - case "http": - wsURL.Scheme = "ws" - case "https": - wsURL.Scheme = "wss" - default: - // Keep as is (might already be ws/wss) - } - - // Set headers - headers := http.Header{} - for k, v := range c.config.Headers { - headers.Set(k, v) - } - - conn, resp, err := c.wsDialer.DialContext(ctx, wsURL.String(), headers) - if err != nil { - if resp != nil { - if resp.Body == nil { - return fmt.Errorf("websocket dial failed (status %d): %w", resp.StatusCode, err) - } - body, readErr := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if readErr != nil { - return fmt.Errorf("websocket dial failed (status %d): %w", resp.StatusCode, err) - } - return fmt.Errorf("websocket dial failed (status %d): %w: %s", resp.StatusCode, err, string(body)) - } - return fmt.Errorf("websocket dial failed: %w", err) - } - if resp != nil && resp.Body != nil { - defer resp.Body.Close() //nolint:errcheck - } - - c.wsConn = conn - return nil -} - -// SendWebSocket sends a JSON-RPC request over WebSocket -func (c *Client) SendWebSocket(ctx context.Context, req JSONRPCRequest) error { - if c.wsConn == nil { - return fmt.Errorf("websocket not connected") - } - - // Build JSON-RPC request envelope - envelope := map[string]any{} - // Allow tests to omit the method by passing "-" (treated as missing field) - if req.Method != "-" && req.Method != "" { - envelope["method"] = req.Method - } - - // Add jsonrpc field if provided, or default to "2.0" - if req.JSONRPC != nil { - if *req.JSONRPC != "" { - envelope["jsonrpc"] = *req.JSONRPC - } - // If JSONRPC is explicitly set to empty string, omit the field - } else { - // Default behavior: include "jsonrpc": "2.0" - envelope["jsonrpc"] = "2.0" - } - - if req.Params != nil { - envelope["params"] = req.Params - } - // Preserve explicit id presence, even if null - if req.HasID { - envelope["id"] = req.ID - } else if req.ID != nil { - envelope["id"] = req.ID - } - - data, err := json.Marshal(envelope) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) - } - - // Set write deadline from context - if deadline, ok := ctx.Deadline(); ok { - if err := c.wsConn.SetWriteDeadline(deadline); err != nil { - return fmt.Errorf("failed to set write deadline: %w", err) - } - } - - return c.wsConn.WriteMessage(websocket.TextMessage, data) -} - -// ReceiveWebSocket receives a message from WebSocket -func (c *Client) ReceiveWebSocket(ctx context.Context) (json.RawMessage, error) { - if c.wsConn == nil { - return nil, fmt.Errorf("websocket not connected") - } - - // Set read deadline from context - if deadline, ok := ctx.Deadline(); ok { - if err := c.wsConn.SetReadDeadline(deadline); err != nil { - return nil, fmt.Errorf("failed to set read deadline: %w", err) - } - } - - messageType, data, err := c.wsConn.ReadMessage() - if err != nil { - // Retry once on abnormal closure to tolerate immediate server close after response - if websocket.IsUnexpectedCloseError(err, websocket.CloseAbnormalClosure) || strings.Contains(err.Error(), "unexpected EOF") { - // Briefly wait and retry a single read within the same deadline window - time.Sleep(10 * time.Millisecond) - messageType, data, err = c.wsConn.ReadMessage() - } - if err != nil { - return nil, err - } - } - - if messageType != websocket.TextMessage { - return nil, fmt.Errorf("unexpected message type: %d", messageType) - } - - return json.RawMessage(data), nil -} - -// CloseWebSocket closes the WebSocket connection gracefully -func (c *Client) CloseWebSocket() error { - if c.wsConn == nil { - return nil - } - - // Send close message - deadline := time.Now().Add(5 * time.Second) - closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") - err := c.wsConn.WriteControl(websocket.CloseMessage, closeMsg, deadline) - - // Always close the connection - closeErr := c.wsConn.Close() - c.wsConn = nil - - // Ignore "broken pipe" errors on close - the server may have already closed - if err != nil && strings.Contains(err.Error(), "broken pipe") { - err = nil - } - if closeErr != nil && strings.Contains(closeErr.Error(), "broken pipe") { - closeErr = nil - } - - // Return the first error - if err != nil { - return err - } - return closeErr -} - -// IsConnected returns true if WebSocket is connected -func (c *Client) IsConnected() bool { - return c.wsConn != nil -} diff --git a/jsonrpc/integration_tests/scenarios/scenarios.yaml b/jsonrpc/integration_tests/scenarios/scenarios.yaml index c9aaca982f..2ad6e08df7 100644 --- a/jsonrpc/integration_tests/scenarios/scenarios.yaml +++ b/jsonrpc/integration_tests/scenarios/scenarios.yaml @@ -181,7 +181,7 @@ scenarios: message: "Invalid params" - # SSE streaming without final response + # SSE streams with event notifications and a terminal null result - name: "stream_object_sse" method: "stream_object_sse" transport: "sse" @@ -217,38 +217,11 @@ scenarios: field2: 3 field3: true # Last item is true - # SSE streaming with final response - - name: "stream_string_final_sse" - method: "stream_string_final_sse" - transport: "sse" - request: - params: "abc" # Length 3 = 3 notifications - id: "sse-1" - sequence: - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_final_sse" - params: - value: "Stream 1 of 3" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_final_sse" - params: - value: "Stream 2 of 3" - type: "receive" expect: jsonrpc: "2.0" - method: "stream_string_final_sse" - params: - value: "Stream 3 of 3" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sse-1" - result: - value: "Final response" + id: "sse-2" + result: null # SSE additional streaming tests - name: "stream_array_sse" @@ -271,6 +244,11 @@ scenarios: method: "stream_array_sse" params: items: ["Processing: second"] + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-array-1" + result: null - name: "stream_map_sse" method: "stream_map_sse" @@ -298,6 +276,11 @@ scenarios: data: key: "key2" value: "value2" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-map-1" + result: null - name: "stream_string_sse" method: "stream_string_sse" @@ -319,111 +302,11 @@ scenarios: params: value: "Stream 2 of 2" - # SSE with final modifier - stream then send final response - - name: "stream_array_final_sse" - method: "stream_array_final_sse" - transport: "sse" - request: - params: - items: ["item1", "item2"] - id: "sse-array-final-1" - sequence: - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_array_final_sse" - params: - items: ["Processing: item1"] - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_array_final_sse" - params: - items: ["Processing: item2"] - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sse-array-final-1" - result: - items: ["completed"] - - - name: "stream_object_final_sse" - method: "stream_object_final_sse" - transport: "sse" - request: - params: - field1: "start" - field2: 3 # Count of notifications before final - field3: false - id: "sse-obj-final-1" - sequence: - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "start-1" - field2: 1 - field3: false - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "start-2" - field2: 2 - field3: false - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "start-3" - field2: 3 - field3: true # Last is true - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sse-obj-final-1" - result: - field1: "completed" - field2: 100 - field3: true - - - name: "stream_map_final_sse" - method: "stream_map_final_sse" - transport: "sse" - request: - params: - data: - first: "value1" - second: "value2" - id: "sse-map-final-1" - sequence: - type: "receive" expect: jsonrpc: "2.0" - method: "stream_map_final_sse" - params: - data: - key: "first" - value: "value1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_map_final_sse" - params: - data: - key: "second" - value: "value2" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sse-map-final-1" - result: - data: - status: "completed" - final: true + id: "sse-string-1" + result: null # SSE error scenario - stream then error - name: "stream_string_error_sse" @@ -450,7 +333,7 @@ scenarios: jsonrpc: "2.0" id: "sse-err-1" error: - code: -32602 + code: -32603 message: "Streaming error occurred" # SSE with no ID (pure notifications) @@ -497,6 +380,12 @@ scenarios: params: items: ["empty"] + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-empty-1" + result: null + # Single string stream - one character = one notification - name: "stream_string_single_sse" method: "stream_string_sse" @@ -512,6 +401,12 @@ scenarios: params: value: "Stream 1 of 1" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-single-1" + result: null + # Multiple items streamed rapidly - name: "stream_string_multiple_sse" method: "stream_string_sse" @@ -551,43 +446,11 @@ scenarios: params: value: "Stream 5 of 5" - # Stream with count control then final response - - name: "stream_object_count_final_sse" - method: "stream_object_final_sse" - transport: "sse" - request: - params: - field1: "test" - field2: 2 # This controls the count of notifications - field3: true - id: "sse-mixed-1" - sequence: - # Notifications based on field2 count - type: "receive" expect: jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "test-1" - field2: 1 - field3: false - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "test-2" - field2: 2 - field3: true # Last item is true - # Then the final response with ID - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sse-mixed-1" - result: - field1: "completed" - field2: 100 - field3: true + id: "sse-rapid-1" + result: null # Echo test for SSE - name: "echo_string_sse" @@ -604,6 +467,12 @@ scenarios: params: value: "echo this" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-echo-1" + result: null + # Transform test for SSE - name: "transform_string_sse" method: "transform_string_sse" @@ -619,6 +488,12 @@ scenarios: params: value: "HELLO WORLD" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-transform-1" + result: null + # Generate test for SSE - name: "generate_string_sse" method: "generate_string_sse" @@ -646,520 +521,11 @@ scenarios: params: value: "generated-3" - # WebSocket tests - TODO: Fix ID field mapping for bidirectional streaming - - name: "echo_string_websocket" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_ws" - params: - id: "ws-1" - value: "hello websocket" - id: "ws-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-1" - result: - value: "hello websocket" - - type: "close" - - # WebSocket with server broadcasts - - name: "broadcast_string_websocket" - method: "broadcast_string_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "broadcast_string_ws" - params: - id: "subscribe" - value: "start" - id: "broadcast-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "broadcast_string_ws" - params: - value: "Server announcement 1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "broadcast_string_ws" - params: - value: "Server announcement 2" - - type: "close" - - # WebSocket transform tests - - name: "transform_string_websocket" - method: "transform_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "transform_string_ws" - params: - id: "ws-transform-1" - value: "hello" - id: "ws-transform-1" - type: "receive" expect: jsonrpc: "2.0" - id: "ws-transform-1" - result: - value: "HELLO" - - type: "close" - - - name: "transform_object_websocket" - method: "transform_object_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "transform_object_ws" - params: - id: "ws-obj-1" - field1: "lower" - field2: 10 - field3: false - id: "ws-obj-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-obj-1" - result: - field1: "LOWER" - field2: 20 - field3: true - - type: "close" - - - name: "transform_map_websocket" - method: "transform_map_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "transform_map_ws" - params: - id: "ws-map-1" - data: - key1: "value1" - key2: "value2" - id: "ws-map-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-map-1" - result: - data: - transformed_key1: "value1" - transformed_key2: "value2" - - type: "close" - - # WebSocket generate tests - - name: "generate_string_websocket" - method: "generate_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "generate_string_ws" - params: - id: "ws-gen-1" - value: "ignored" - id: "ws-gen-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-gen-1" - result: - value: "generated-string" - - type: "close" - - - name: "generate_array_websocket" - method: "generate_array_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "generate_array_ws" - params: - id: "ws-gen-array-1" - items: [] # Ignored - id: "ws-gen-array-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-gen-array-1" - result: - items: ["item1", "item2", "item3"] - - type: "close" - - - name: "generate_object_websocket" - method: "generate_object_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "generate_object_ws" - params: - id: "ws-gen-obj-1" - field1: "" - field2: 0 - field3: false - id: "ws-gen-obj-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-gen-obj-1" - result: - field1: "generated-value1" - field2: 42 - field3: true - - type: "close" - - # WebSocket stream tests (server streaming) - - name: "stream_string_websocket" - method: "stream_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "stream_string_ws" - params: - id: "ws-stream-1" - value: "ab" # 2 chars = 2 messages - id: "ws-stream-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_ws" - params: - value: "Stream 1 of 2" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_ws" - params: - value: "Stream 2 of 2" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-stream-1" - result: - value: "completed" - - type: "close" - - - name: "stream_array_websocket" - method: "stream_array_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "stream_array_ws" - params: - id: "ws-stream-array-1" - items: ["first", "second"] - id: "ws-stream-array-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_array_ws" - params: - items: ["Processing: first"] - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_array_ws" - params: - items: ["Processing: second"] - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-stream-array-1" - result: - items: ["completed"] - - type: "close" - - - name: "stream_object_websocket" - method: "stream_object_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "stream_object_ws" - params: - id: "ws-stream-obj-1" - field1: "test" - field2: 2 # Controls stream count - field3: false - id: "ws-stream-obj-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_ws" - params: - field1: "test-1" - field2: 1 - field3: false - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_ws" - params: - field1: "test-2" - field2: 2 - field3: true - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-stream-obj-1" - result: - field1: "completed" - field2: 100 - field3: true - - type: "close" - - # WebSocket error handling tests - - name: "echo_string_error_websocket" - method: "echo_string_error_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_error_ws" - params: - id: "ws-error-1" - value: "will fail" - id: "ws-error-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-error-1" - error: - code: -32602 - message: "Invalid params" - - type: "close" - - - name: "stream_string_error_websocket" - method: "stream_string_error_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "stream_string_error_ws" - params: - id: "ws-stream-error-1" - value: "fail" - id: "ws-stream-error-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_error_ws" - params: - value: "Stream 1 of 4" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_error_ws" - params: - value: "Stream 2 of 4" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-stream-error-1" - error: - code: -32602 - message: "Streaming error occurred" - - type: "close" - - # WebSocket protocol compliance: request vs notification (JSON-RPC version and method presence) - - name: "websocket_invalid_version_request" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "-" # Omit version field - method: "echo_string_ws" - params: - id: "ws-invalid-1" - value: "ignore" - id: "ws-invalid-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-invalid-1" - error: - code: -32600 - message: "Invalid request" - - - name: "websocket_invalid_version_notification" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "-" # Omit version field - method: "echo_string_ws" - params: - value: "notification without version" - # No receive expected for notifications with invalid request - - - name: "websocket_missing_method_with_id" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "-" # Omit method field - params: - value: "test" - id: "ws-missing-method-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-missing-method-1" - error: - code: -32600 - message: "Invalid request" - - - name: "websocket_missing_method_notification" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "-" # Omit method field - params: - value: "notification with missing method" - # No receive expected - - - name: "websocket_method_not_found" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "non_existent_method_ws" - params: - value: "test" - id: "ws-not-found-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-not-found-1" - error: - code: -32601 - message: "Method not found" - - # WebSocket notification tests (no response expected) - - name: "echo_string_notify_websocket" - method: "echo_string_notify_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_notify_ws" - params: - value: "notification" - # No id field - this is a notification - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_notify_ws" - params: - value: "another notification" - # No id field - # No receive expected for notifications - - type: "close" - - # WebSocket validation test - - name: "echo_object_validate_websocket" - method: "echo_object_validate_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_object_validate_ws" - params: - id: "ws-validate-1" - field1: "" # Empty string might fail validation - field2: -1 # Negative number might fail validation - field3: true - id: "ws-validate-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-validate-1" - error: - code: -32602 - message: "validation error" - - type: "close" - - # WebSocket bidirectional streaming - - name: "collect_array_websocket" - method: "collect_array_ws" - transport: "websocket" - sequence: - - type: "send" - data: - method: "collect_array_ws" - params: - id: "collect-1" - items: ["first"] - id: "collect-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "collect-1" - result: - items: ["first"] - - type: "send" - data: - method: "collect_array_ws" - params: - id: "collect-2" - items: ["second"] - id: "collect-2" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "collect-2" - result: - items: ["first", "second"] - - type: "send" - data: - method: "collect_array_ws" - params: - id: "collect-3" - items: ["third"] - id: "collect-3" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "collect-3" - result: - items: ["first", "second", "third"] + id: "sse-generate-1" + result: null # Batch request tests - name: "batch_mixed_requests" @@ -1292,6 +658,12 @@ scenarios: params: value: "Stream 4 of 4" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "mixed-sse-1" + result: null + # Mixed HTTP (CLI) and JSON-RPC (direct) for the same method - name: "mixed_http_cli_echo" method: "echo_string" @@ -1332,24 +704,28 @@ scenarios: # (to be added in harness/runner extensions if we choose to assert both paths here). # Additional HTTP protocol edge cases - - name: "http_invalid_version_notification" + - name: "http_invalid_version_request" transport: "http" request: jsonrpc: "-" # Omit version field method: "echo_string" params: "notify" - # No id -> notification + # No ID. The malformed object is still an invalid request, not a notification. expect: - no_response: true + error: + code: -32600 + message: "Invalid request" - - name: "http_missing_method_notification" + - name: "http_missing_method_request" transport: "http" request: jsonrpc: "2.0" - # No method field (invalid request) but notification (no id) + # No method or ID. A notification must still be a valid request object. params: "notify" expect: - no_response: true + error: + code: -32600 + message: "Missing method field" - name: "http_missing_method_with_id_raw" transport: "http" @@ -1360,49 +736,6 @@ scenarios: code: -32600 message: "Invalid request" - # Additional WebSocket ID-type coverage - - name: "websocket_null_id" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_ws" - params: - id: null - value: "null id" - id: null - - type: "receive" - expect: - jsonrpc: "2.0" - id: null - result: - value: "null id" - - type: "close" - - - name: "websocket_numeric_id" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_ws" - params: - id: 42 - value: "number id" - id: 42 - - type: "receive" - expect: - jsonrpc: "2.0" - id: 42 - result: - value: "number id" - - type: "close" - # Additional SSE protocol edge cases - name: "sse_invalid_version_with_id" method: "echo_string_sse" @@ -1492,47 +825,8 @@ scenarios: method: "stream_string_sse" params: value: "Stream 2 of 2" - - - name: "websocket_params_id_with_request_id_mapping" - method: "echo_string_idmap_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_idmap_ws" - params: - id: "biz-42" # business-level id as string - request_id: "foo" # separate app field; mapping uses envelope id - value: "ws-hello" - id: "env-ws-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "env-ws-1" - result: - value: "ws-hello" - - type: "close" - - - name: "websocket_params_id_with_request_id_mapping_numeric_env_id" - method: "echo_string_idmap_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_idmap_ws" - params: - id: "biz-456" - request_id: "foo" - value: "ws-hello-num" - id: 202 - type: "receive" expect: jsonrpc: "2.0" - id: 202 - result: - value: "ws-hello-num" - - type: "close" \ No newline at end of file + id: "env-sse-1" + result: null diff --git a/jsonrpc/types.go b/jsonrpc/types.go index 74155e98d8..7051d8312b 100644 --- a/jsonrpc/types.go +++ b/jsonrpc/types.go @@ -36,9 +36,12 @@ type ( Method string `json:"method"` Params json.RawMessage `json:"params,omitempty"` ID any `json:"id"` - // HasID is true when the "id" key is present in the incoming JSON (even if null). - // It is consumed by generated templates (WebSocket/SSE/HTTP) to decide whether - // to send a response for this request. Do not remove even if unused by this package. + // Invalid is true when the JSON value is not shaped like a JSON-RPC + // request object. + Invalid bool `json:"-"` + // HasID is true when the "id" key is present in the incoming JSON, even + // when its value is null. Generated servers use it to decide whether to + // send a response for this request. HasID bool `json:"-"` } @@ -114,6 +117,31 @@ func MakeNotification(method string, params any) *Request { } } +// MarshalJSON writes the result member for every success response, including +// responses whose result is null, and writes only the error for failures. +func (r *Response) MarshalJSON() ([]byte, error) { + if r.Error != nil { + return json.Marshal(struct { + JSONRPC string `json:"jsonrpc"` + Error *ErrorResponse `json:"error"` + ID any `json:"id"` + }{ + JSONRPC: r.JSONRPC, + Error: r.Error, + ID: r.ID, + }) + } + return json.Marshal(struct { + JSONRPC string `json:"jsonrpc"` + Result any `json:"result"` + ID any `json:"id"` + }{ + JSONRPC: r.JSONRPC, + Result: r.Result, + ID: r.ID, + }) +} + // Error returns a string representation of the error. func (e *ErrorResponse) Error() string { return fmt.Sprintf("jsonrpc: code %d: %s", e.Code, e.Message) @@ -137,40 +165,48 @@ func IDToString(id any) string { } } -// UnmarshalJSON decodes RawRequest and records whether the id field was present. +// UnmarshalJSON decodes one request and records invalid input and ID presence. func (r *RawRequest) UnmarshalJSON(data []byte) error { + *r = RawRequest{} var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { + if json.Valid(data) { + r.Invalid = true + return nil + } return err } + if raw == nil { + r.Invalid = true + return nil + } + if v, ok := raw["id"]; ok { + r.HasID = true + if string(v) != "null" { + if err := json.Unmarshal(v, &r.ID); err != nil { + r.Invalid = true + } else { + switch r.ID.(type) { + case string, float64: + default: + r.ID = nil + r.Invalid = true + } + } + } + } if v, ok := raw["jsonrpc"]; ok { - if err := json.Unmarshal(v, &r.JSONRPC); err != nil { - return err + if json.Unmarshal(v, &r.JSONRPC) != nil { + r.Invalid = true } } if v, ok := raw["method"]; ok { - if err := json.Unmarshal(v, &r.Method); err != nil { - return err + if json.Unmarshal(v, &r.Method) != nil { + r.Invalid = true } } if v, ok := raw["params"]; ok { r.Params = v } - if v, ok := raw["id"]; ok { - r.HasID = true - // Preserve null vs non-null values - if string(v) == "null" { - r.ID = nil - } else { - var id any - if err := json.Unmarshal(v, &id); err != nil { - return err - } - r.ID = id - } - } else { - r.HasID = false - r.ID = nil - } return nil } diff --git a/jsonrpc/websocket_config.go b/jsonrpc/websocket_config.go deleted file mode 100644 index 7291ba538e..0000000000 --- a/jsonrpc/websocket_config.go +++ /dev/null @@ -1,193 +0,0 @@ -package jsonrpc - -import ( - "context" - "time" -) - -type ( - // StreamErrorType represents different types of WebSocket stream errors - StreamErrorType int - - // StreamErrorHandler allows users to handle stream errors - StreamErrorHandler func(ctx context.Context, errorType StreamErrorType, err error, response *RawResponse) - - // StreamConfig contains configuration options for WebSocket streams - StreamConfig struct { - // Timeouts - RequestTimeout time.Duration // Timeout for individual requests (default: 30s) - ConnectionTimeout time.Duration // Timeout for establishing connections (default: 10s) - CloseTimeout time.Duration // Timeout for graceful stream closure (default: 5s) - - // Buffer Sizes - ResultChannelBuffer int // Buffer size for result channels (default: 1) - WriteBufferSize int // WebSocket write buffer size (default: 4096) - ReadBufferSize int // WebSocket read buffer size (default: 4096) - - // Retry Configuration - MaxRetries int // Maximum number of connection retries (default: 3) - RetryBackoffBase time.Duration // Base delay for exponential backoff (default: 1s) - RetryBackoffMax time.Duration // Maximum retry delay (default: 30s) - - // Advanced Options - EnableCompression bool // Enable WebSocket compression (default: false) - PingInterval time.Duration // Interval for sending ping frames (default: 30s) - - // Error Handling - ErrorHandler StreamErrorHandler // Optional error handler for stream events (default: nil) - } - - // StreamConfigOption is a function that modifies StreamConfig - StreamConfigOption func(*StreamConfig) -) - -const ( - StreamErrorConnection StreamErrorType = iota // WebSocket connection errors - StreamErrorProtocol // Invalid JSON-RPC protocol - StreamErrorParsing // Failed to parse/decode response - StreamErrorOrphaned // Response with no matching request - StreamErrorTimeout // Request timeout - StreamErrorNotification // Server-initiated notification received -) - -// WithRequestTimeout sets the timeout for individual requests -func WithRequestTimeout(timeout time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.RequestTimeout = timeout - } -} - -// WithConnectionTimeout sets the timeout for establishing connections -func WithConnectionTimeout(timeout time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.ConnectionTimeout = timeout - } -} - -// WithCloseTimeout sets the timeout for graceful stream closure -func WithCloseTimeout(timeout time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.CloseTimeout = timeout - } -} - -// WithResultChannelBuffer sets the buffer size for result channels -func WithResultChannelBuffer(size int) StreamConfigOption { - return func(c *StreamConfig) { - c.ResultChannelBuffer = size - } -} - -// WithWebSocketBuffers sets both read and write buffer sizes -func WithWebSocketBuffers(readSize, writeSize int) StreamConfigOption { - return func(c *StreamConfig) { - c.ReadBufferSize = readSize - c.WriteBufferSize = writeSize - } -} - -// WithRetryConfig sets retry behavior parameters -func WithRetryConfig(maxRetries int, baseDelay, maxDelay time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.MaxRetries = maxRetries - c.RetryBackoffBase = baseDelay - c.RetryBackoffMax = maxDelay - } -} - -// WithCompression enables or disables WebSocket compression -func WithCompression(enabled bool) StreamConfigOption { - return func(c *StreamConfig) { - c.EnableCompression = enabled - } -} - -// WithPingInterval sets the interval for sending ping frames -func WithPingInterval(interval time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.PingInterval = interval - } -} - -// WithErrorHandler sets the error handler for stream events -func WithErrorHandler(handler StreamErrorHandler) StreamConfigOption { - return func(c *StreamConfig) { - c.ErrorHandler = handler - } -} - -// NewStreamConfig creates a StreamConfig with the given options -func NewStreamConfig(opts ...StreamConfigOption) *StreamConfig { - config := defaultStreamConfig() - for _, opt := range opts { - opt(config) - } - return config.Validate() -} - -// defaultStreamConfig returns a StreamConfig with sensible production defaults -func defaultStreamConfig() *StreamConfig { - return &StreamConfig{ - // Reasonable timeout defaults - RequestTimeout: 30 * time.Second, - ConnectionTimeout: 10 * time.Second, - CloseTimeout: 5 * time.Second, - - // Conservative buffer sizes - ResultChannelBuffer: 1, - WriteBufferSize: 4096, - ReadBufferSize: 4096, - - // Moderate retry behavior - MaxRetries: 3, - RetryBackoffBase: 1 * time.Second, - RetryBackoffMax: 30 * time.Second, - - // Safe advanced defaults - EnableCompression: false, - PingInterval: 30 * time.Second, - } -} - -// Validate checks the configuration and applies constraints -func (c *StreamConfig) Validate() *StreamConfig { - // Ensure positive timeouts - if c.RequestTimeout <= 0 { - c.RequestTimeout = 30 * time.Second - } - if c.ConnectionTimeout <= 0 { - c.ConnectionTimeout = 10 * time.Second - } - if c.CloseTimeout <= 0 { - c.CloseTimeout = 5 * time.Second - } - - // Ensure reasonable buffer sizes - if c.ResultChannelBuffer < 1 { - c.ResultChannelBuffer = 1 - } - if c.WriteBufferSize < 1024 { - c.WriteBufferSize = 1024 - } - if c.ReadBufferSize < 1024 { - c.ReadBufferSize = 1024 - } - - // Ensure reasonable retry configuration - if c.MaxRetries < 0 { - c.MaxRetries = 0 - } - if c.RetryBackoffBase <= 0 { - c.RetryBackoffBase = 1 * time.Second - } - if c.RetryBackoffMax < c.RetryBackoffBase { - c.RetryBackoffMax = c.RetryBackoffBase * 30 - } - - // Ensure reasonable ping interval - if c.PingInterval <= 0 { - c.PingInterval = 30 * time.Second - } - - return c -}