11package generate
22
33import (
4+ "cmp"
45 "context"
56 "errors"
67 "fmt"
3637// published contracts mark as an operation.
3738var 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
4345A method that declares its authority policy (authority.hop.v1.policy) is an
4446operation 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
4951descriptors say what the contract is, by their proto3 JSON. Nothing is written
5052twice.
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+
5260The 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.
5463Run that first. Only served methods are derived; a declared Lookup must be
5564served 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+
6374The tree is fully owned: a method that is no longer an operation loses its
6475directory. 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.
7485Examples:
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.
171183type 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.
348370func 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