Skip to content

Commit 89555d6

Browse files
feat(generate): a solution's routes become Runnables, and so tools, as a module's methods do (#1184)
`generate runnables` derives every route a module's OpenAPI contract marks x-codefly-operation through the same location, file and index writers a method goes through (deriveOpenAPIEndpoint / deriveRoute), one directory per route named by its operationId. solution-runtime-go marks each read a solution backend serves, so its GET reads become read-only Runnables whose query is their input. `generate runnable-bindings` prepares them keyed SOLUTION__OPERATION with a rest route; callTarget now calls an http endpoint that publishes OpenAPI exactly as it calls a rest one. Core pinned to the codefly-dev/core#852 head (GET operations, Swagger 2.0, RouteMarker). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 3ba96cd commit 89555d6

6 files changed

Lines changed: 462 additions & 47 deletions

File tree

‎cmd/generate/runnable_bindings.go‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ For each derived operation the value is one JSON document
5959
(` + corerunnable.PreparedSchemaV3 + `):
6060
6161
operation the owner coordinates, and the operation as the owner spells
62-
it: /pkg.Service/Method, or "POST /path"
62+
it: /pkg.Service/Method, or "GET /path" for a route
6363
call where one call is sent: the resolved base URL, and a typed
6464
route — connect (the procedure, POSTed as JSON on the owner's
6565
Connect endpoint) or rest (the owner's own verb and path)
@@ -76,6 +76,9 @@ A gRPC owner is therefore called on its **Connect** endpoint: a service that
7676
publishes a runnable-marked method and declares no connect endpoint is refused
7777
here, by name, rather than at a call.
7878
An operation published directly on a Connect endpoint uses that endpoint.
79+
A solution's route is called on the http (or rest) endpoint whose OpenAPI
80+
document published it, with its own verb and path, and keyed by the solution's
81+
name exactly as a module operation is by its module's (SOLUTION__OPERATION).
7982
8083
The address is resolved, never configured: it is the one a run or a render of
8184
the same environment gives the owner. Locally that is the address of the run's
@@ -661,7 +664,10 @@ func (r *endpointResolver) callTarget(ctx context.Context, module *resources.Mod
661664
Address: address,
662665
Route: &runnablev0.PreparedCall_Connect{Connect: &runnablev0.ConnectProcedure{Procedure: spelling}},
663666
}, composition.APIContractKindProtobuf, nil
664-
case standards.REST:
667+
case standards.REST, standards.HTTP:
668+
// A solution's backend serves its routes on an http endpoint and
669+
// publishes them as OpenAPI, which verifyPublishedContract holds it to:
670+
// it is called exactly as a REST owner is.
665671
address, err := r.address(ctx, identity, endpoints, published)
666672
if err != nil {
667673
return nil, "", err
@@ -675,8 +681,8 @@ func (r *endpointResolver) callTarget(ctx context.Context, module *resources.Mod
675681
Route: &runnablev0.PreparedCall_Rest{Rest: &runnablev0.HTTPRoute{Verb: verb, Path: path}},
676682
}, composition.APIContractKindOpenAPI, nil
677683
default:
678-
return nil, "", fmt.Errorf("%s/%s/%s is a %s endpoint, and a derived operation is called over %s, %s or %s",
679-
module.Name, serviceName, publishedName, published.GetApi(), standards.GRPC, standards.CONNECT, standards.REST)
684+
return nil, "", fmt.Errorf("%s/%s/%s is a %s endpoint, and a derived operation is called over %s, %s, %s or %s",
685+
module.Name, serviceName, publishedName, published.GetApi(), standards.GRPC, standards.CONNECT, standards.REST, standards.HTTP)
680686
}
681687
}
682688

‎cmd/generate/runnables.go‎

Lines changed: 143 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package generate
22

33
import (
4+
"cmp"
45
"context"
56
"errors"
67
"fmt"
@@ -36,9 +37,10 @@ var (
3637
// published contracts mark as an operation.
3738
var RunnablesCmd = &cobra.Command{
3839
Use: "runnables [module]",
39-
Short: "Derive SERVICE-facility Runnable packages, and so tools, from a module's served methods",
40+
Short: "Derive SERVICE-facility Runnable packages, and so tools, from a module's served methods and a solution's routes",
4041
Long: `Derive a Runnable package for every gRPC method a module's published contracts
41-
serve behind the authority hop. Every Runnable is a tool.
42+
serve behind the authority hop, and every REST route a solution's published
43+
OpenAPI document marks. Every Runnable is a tool.
4244
4345
A method that declares its authority policy (authority.hop.v1.policy) is an
4446
operation with nothing added: a policy requiring only "read" derives a
@@ -49,8 +51,15 @@ codefly.runnable.v0.exclude keeps a method from every agent. The message
4951
descriptors say what the contract is, by their proto3 JSON. Nothing is written
5052
twice.
5153
54+
A solution publishes its routes as one OpenAPI document (Swagger 2.0 or
55+
OpenAPI 3), and every route marked x-codefly-operation derives the same way:
56+
solution-runtime-go marks each read the backend serves, so a GET read becomes a
57+
read-only tool whose query parameters are its input. Its directory is named by
58+
the route's operationId. No flag names a route.
59+
5260
The input is what ` + "`codefly generate contracts`" + ` already wrote — each endpoint's
53-
served procedures in the API catalog and its serialized FileDescriptorSet.
61+
served procedures in the API catalog and its serialized FileDescriptorSet, or
62+
its OpenAPI document.
5463
Run that first. Only served methods are derived; a declared Lookup must be
5564
served on the same endpoint. No flag names a method.
5665
@@ -60,6 +69,8 @@ For each marked method this writes, under contracts/runnables:
6069
<service>/<endpoint>/<Method>/operation.json its execution policy and authority
6170
index.json one row per operation
6271
72+
and the same, under <service>/<endpoint>/<operationId>/, for each marked route.
73+
6374
The tree is fully owned: a method that is no longer an operation loses its
6475
directory. A module with no operation writes an empty index, not an error.
6576
@@ -74,6 +85,7 @@ prints a unified diff of what differs, without writing anything.
7485
Examples:
7586
codefly generate runnables
7687
codefly generate runnables documents
88+
codefly generate runnables releases # a solution: its marked routes
7789
codefly generate runnables --check`,
7890
Args: cobra.MaximumNArgs(1),
7991
RunE: func(_ *cobra.Command, args []string) error {
@@ -170,7 +182,9 @@ func (d *derivation) refusal() error {
170182
// from and what it does.
171183
type derivedOperation struct {
172184
explicit bool
173-
effect string
185+
// marker is a route its document marks (x-codefly-operation).
186+
marker bool
187+
effect string
174188
}
175189

176190
// report says what the run made of the module, so an owner learns which of
@@ -187,7 +201,10 @@ func (d *derivation) report() {
187201
entry := &d.index.Operations[i]
188202
operation := d.origins[entry.Name]
189203
origin := "option"
190-
if !operation.explicit {
204+
switch {
205+
case operation.marker:
206+
origin = "marker"
207+
case !operation.explicit:
191208
origin = "default"
192209
defaults = appendOnce(defaults, entry.Method)
193210
if operation.effect == runnablev0.ToolExposure_EFFECT_MUTATION.String() {
@@ -259,7 +276,12 @@ func deriveRunnablesIn(ctx context.Context, workspaceName, workspaceDir string,
259276
agents := map[string]*basev0.Agent{}
260277
for i := range endpoints {
261278
endpoint := &endpoints[i]
262-
if endpoint.Kind != composition.APIContractKindProtobuf {
279+
derive := deriveEndpoint
280+
switch endpoint.Kind {
281+
case composition.APIContractKindProtobuf:
282+
case composition.APIContractKindOpenAPI:
283+
derive = deriveOpenAPIEndpoint
284+
default:
263285
continue
264286
}
265287
agent, ok := agents[endpoint.Service]
@@ -269,7 +291,7 @@ func deriveRunnablesIn(ctx context.Context, workspaceName, workspaceDir string,
269291
}
270292
agents[endpoint.Service] = agent
271293
}
272-
if err = deriveEndpoint(module, endpoint, agent, derived); err != nil {
294+
if err = derive(module, endpoint, agent, derived); err != nil {
273295
return nil, err
274296
}
275297
}
@@ -347,54 +369,136 @@ func deriveEndpoint(module *resources.Module, endpoint *composition.APIContractE
347369
// derivation refuses is recorded and the walk continues.
348370
func deriveMethod(module *resources.Module, endpoint *composition.APIContractEndpoint, owner corerunnable.ServiceOwner, files *protoregistry.Files, fullMethod string, served []string, derived *derivation) error {
349371
name := methodName(fullMethod)
350-
relativeDir, err := derivedRelativeDir(derived.workspaceDir, module, endpoint, name)
372+
location, err := derived.location(module, endpoint, name, name)
351373
if err != nil {
352374
return err
353375
}
354-
location := &resources.RunnableLocation{
355-
Identity: &resources.RunnableIdentity{
356-
Name: derivedName(endpoint.Service, endpoint.Endpoint, name),
357-
Module: module.Name,
358-
Workspace: derived.index.Workspace,
359-
Version: derivedVersion(derived.release, endpoint),
360-
},
361-
WorkspacePath: derived.workspaceDir,
362-
RelativeToWorkspace: relativeDir,
376+
pkg, spec, err := corerunnable.PackageFromMethod(files, location, owner, fullMethod)
377+
if !derived.admitted(fullMethod, err) {
378+
return nil
379+
}
380+
if spec.LookupMethod != "" && !slices.Contains(served, spec.LookupMethod) {
381+
derived.refusals = append(derived.refusals, fmt.Sprintf("%s: endpoint %s/%s does not serve lookup method %s", fullMethod, endpoint.Service, endpoint.Endpoint, spec.LookupMethod))
382+
return nil
363383
}
384+
return derived.record(endpoint, name, pkg, spec, derivedOperation{explicit: spec.Declared, effect: spec.Tool.GetEffect().String()})
385+
}
364386

365-
pkg, spec, err := corerunnable.PackageFromMethod(files, location, owner, fullMethod)
387+
// deriveOpenAPIEndpoint derives every route one OpenAPI contract marks with
388+
// x-codefly-operation — a solution's backend publishes one, marking each read
389+
// it serves — through the same writers a method goes through, so a route and a
390+
// method land in one tree, one index and one binding key space. Routes are
391+
// walked in path then method order, so the index is a function of the document.
392+
func deriveOpenAPIEndpoint(module *resources.Module, endpoint *composition.APIContractEndpoint, agent *basev0.Agent, derived *derivation) error {
393+
contract := filepath.Join(module.Dir(), filepath.FromSlash(endpoint.Path))
394+
document, err := os.ReadFile(contract) //nolint:gosec // the path comes from the module's own contract catalog
395+
if err != nil {
396+
return fmt.Errorf("cannot read the contract of %s/%s: %w", endpoint.Service, endpoint.Endpoint, err)
397+
}
398+
parsed, err := corerunnable.ParseOpenAPIDocument(document)
366399
if err != nil {
367-
if errors.Is(err, corerunnable.ErrNotAnOperation) {
368-
return nil
400+
return fmt.Errorf("cannot read %s: %w", contract, err)
401+
}
402+
owner := corerunnable.ServiceOwner{Module: module.Name, Service: endpoint.Service, Endpoint: endpoint.Endpoint, Agent: agent}
403+
paths := make([]string, 0, len(parsed.Paths))
404+
for path := range parsed.Paths {
405+
paths = append(paths, path)
406+
}
407+
sort.Strings(paths)
408+
for _, path := range paths {
409+
item := parsed.Paths[path]
410+
if item == nil {
411+
continue
369412
}
370-
if errors.Is(err, corerunnable.ErrNotDerivable) {
371-
derived.notices = append(derived.notices, fmt.Sprintf("%s: %v", fullMethod, err))
372-
return nil
413+
for _, route := range []struct {
414+
method string
415+
operation *corerunnable.OpenAPIOperation
416+
}{{"GET", item.Get}, {"POST", item.Post}, {"PUT", item.Put}} {
417+
if route.operation == nil || len(route.operation.Marker) == 0 {
418+
continue
419+
}
420+
if err = deriveRoute(module, endpoint, owner, document, route.method, path, route.operation.OperationID, derived); err != nil {
421+
return err
422+
}
373423
}
374-
derived.refusals = append(derived.refusals, fmt.Sprintf("%s: %v", fullMethod, err))
375-
return nil
376424
}
377-
if spec.LookupMethod != "" && !slices.Contains(served, spec.LookupMethod) {
378-
derived.refusals = append(derived.refusals, fmt.Sprintf("%s: endpoint %s/%s does not serve lookup method %s", fullMethod, endpoint.Service, endpoint.Endpoint, spec.LookupMethod))
425+
return nil
426+
}
427+
428+
// deriveRoute derives one marked route. Its directory is named by the route's
429+
// operationId — the identity the document publishes it under, as a method's
430+
// is its name — or, when the document gives none, by the route itself.
431+
func deriveRoute(module *resources.Module, endpoint *composition.APIContractEndpoint, owner corerunnable.ServiceOwner, document []byte, method, path, operationID string, derived *derivation) error {
432+
spelling := corerunnable.Route(method, path)
433+
words := strings.FieldsFunc(cmp.Or(operationID, spelling), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) })
434+
for i, word := range words {
435+
words[i] = kebab(word)
436+
}
437+
name := cmp.Or(operationID, strings.Join(words, "-"))
438+
location, err := derived.location(module, endpoint, name, strings.Join(words, "-"))
439+
if err != nil {
440+
return err
441+
}
442+
pkg, spec, err := corerunnable.PackageFromOpenAPIOperation(document, location, owner, method, path)
443+
if !derived.admitted(spelling, err) {
379444
return nil
380445
}
446+
return derived.record(endpoint, name, pkg, spec, derivedOperation{marker: true, effect: spec.Tool.GetEffect().String()})
447+
}
381448

449+
// location is where one operation's release lives: its directory, named dir,
450+
// and its identity, the release name spelled from words.
451+
func (d *derivation) location(module *resources.Module, endpoint *composition.APIContractEndpoint, dir, words string) (*resources.RunnableLocation, error) {
452+
relativeDir, err := derivedRelativeDir(d.workspaceDir, module, endpoint, dir)
453+
if err != nil {
454+
return nil, err
455+
}
456+
return &resources.RunnableLocation{
457+
Identity: &resources.RunnableIdentity{
458+
Name: derivedName(endpoint.Service, endpoint.Endpoint, words),
459+
Module: module.Name,
460+
Workspace: d.index.Workspace,
461+
Version: derivedVersion(d.release, endpoint),
462+
},
463+
WorkspacePath: d.workspaceDir,
464+
RelativeToWorkspace: relativeDir,
465+
}, nil
466+
}
467+
468+
// admitted sorts one derivation's answer: no operation is an answer, an
469+
// operation the default derivation cannot make is a notice, anything else core
470+
// refuses is a refusal, and only a derived operation is admitted.
471+
func (d *derivation) admitted(subject string, err error) bool {
472+
switch {
473+
case err == nil:
474+
return true
475+
case errors.Is(err, corerunnable.ErrNotAnOperation):
476+
case errors.Is(err, corerunnable.ErrNotDerivable):
477+
d.notices = append(d.notices, fmt.Sprintf("%s: %v", subject, err))
478+
default:
479+
d.refusals = append(d.refusals, fmt.Sprintf("%s: %v", subject, err))
480+
}
481+
return false
482+
}
483+
484+
// record writes one derived operation into the tree and the index.
485+
func (d *derivation) record(endpoint *composition.APIContractEndpoint, name string, pkg *basev0.RunnablePackage, spec *corerunnable.OperationSpec, origin derivedOperation) error {
486+
operation := pkg.GetServiceOperations()[0]
382487
packageBytes, err := runnablespkg.CanonicalJSON(pkg)
383488
if err != nil {
384-
return fmt.Errorf("%s: cannot encode the derived package: %w", fullMethod, err)
489+
return fmt.Errorf("%s: cannot encode the derived package: %w", operation.GetOperation(), err)
385490
}
386491
operationBytes, err := marshalIndented(newOperationDocument(spec))
387492
if err != nil {
388-
return fmt.Errorf("%s: cannot encode the derived operation: %w", fullMethod, err)
493+
return fmt.Errorf("%s: cannot encode the derived operation: %w", operation.GetOperation(), err)
389494
}
390495

391496
dir := filepath.Join(endpoint.Service, endpoint.Endpoint, name)
392-
derived.files[filepath.ToSlash(filepath.Join(dir, runnablespkg.PackageFileName))] = append(packageBytes, '\n')
393-
derived.files[filepath.ToSlash(filepath.Join(dir, runnablespkg.OperationFileName))] = operationBytes
497+
d.files[filepath.ToSlash(filepath.Join(dir, runnablespkg.PackageFileName))] = append(packageBytes, '\n')
498+
d.files[filepath.ToSlash(filepath.Join(dir, runnablespkg.OperationFileName))] = operationBytes
394499

395-
operation := pkg.GetServiceOperations()[0]
396-
derived.origins[pkg.GetIdentity().GetName()] = derivedOperation{explicit: spec.Declared, effect: spec.Tool.GetEffect().String()}
397-
derived.index.Operations = append(derived.index.Operations, runnablespkg.IndexEntry{
500+
d.origins[pkg.GetIdentity().GetName()] = origin
501+
d.index.Operations = append(d.index.Operations, runnablespkg.IndexEntry{
398502
Name: pkg.GetIdentity().GetName(),
399503
Version: pkg.GetIdentity().GetVersion(),
400504
Digest: pkg.GetDigest(),
@@ -484,7 +588,9 @@ func derivedVersion(release string, endpoint *composition.APIContractEndpoint) s
484588
return "0.0.0-contract-" + digest
485589
}
486590

487-
// derivedName names the release a method derives. The endpoint is part of it
591+
// derivedName names the release an operation derives, its words spelled as a
592+
// resource name is (kebab): a method's name, or a route's words, which are
593+
// already. The endpoint is part of it
488594
// because one proto service may be published on both a grpc and a connect
489595
// endpoint: those are two operations reached two ways, and a name that dropped
490596
// the endpoint would give them one identity and two digests.
@@ -493,8 +599,8 @@ func derivedVersion(release string, endpoint *composition.APIContractEndpoint) s
493599
// shortened deterministically — a stable prefix and a short digest of the
494600
// whole name — so a long method name never fails generation and two long
495601
// names never collapse into one.
496-
func derivedName(service, endpoint, method string) string {
497-
return corerunnable.BoundedName(strings.Join([]string{service, endpoint, kebab(method)}, "-"), maxDerivedName, "-")
602+
func derivedName(service, endpoint, words string) string {
603+
return corerunnable.BoundedName(strings.Join([]string{service, endpoint, kebab(words)}, "-"), maxDerivedName, "-")
498604
}
499605

500606
// appendOnce appends value unless the list already holds it.

0 commit comments

Comments
 (0)