diff --git a/api/app/app_v1alpha/rpc.gen.go b/api/app/app_v1alpha/rpc.gen.go index 9071b3857..8c8cbd506 100644 --- a/api/app/app_v1alpha/rpc.gen.go +++ b/api/app/app_v1alpha/rpc.gen.go @@ -3287,6 +3287,12 @@ func AdaptCrud(t Crud) *rpc.Interface { Index: 0, Public: false, Params: []string{"name"}, + HTTP: &rpc.HTTPBinding{ + Verb: "POST", + Path: "/api/v1/apps", + Body: "*", + PathParams: []string{}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.New(ctx, &CrudNew{Call: call}) }, @@ -3297,6 +3303,12 @@ func AdaptCrud(t Crud) *rpc.Interface { Index: 0, Public: false, Params: []string{"app", "configuration"}, + HTTP: &rpc.HTTPBinding{ + Verb: "PUT", + Path: "/api/v1/apps/{app}/config", + Body: "*", + PathParams: []string{"app"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.SetConfiguration(ctx, &CrudSetConfiguration{Call: call}) }, @@ -3307,6 +3319,12 @@ func AdaptCrud(t Crud) *rpc.Interface { Index: 0, Public: false, Params: []string{"app"}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/api/v1/apps/{app}/config", + Body: "", + PathParams: []string{"app"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.GetConfiguration(ctx, &CrudGetConfiguration{Call: call}) }, @@ -3317,6 +3335,12 @@ func AdaptCrud(t Crud) *rpc.Interface { Index: 0, Public: false, Params: []string{"app", "host"}, + HTTP: &rpc.HTTPBinding{ + Verb: "PUT", + Path: "/api/v1/apps/{app}/host", + Body: "*", + PathParams: []string{"app"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.SetHost(ctx, &CrudSetHost{Call: call}) }, @@ -3327,6 +3351,12 @@ func AdaptCrud(t Crud) *rpc.Interface { Index: 0, Public: false, Params: []string{}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/api/v1/apps", + Body: "", + PathParams: []string{}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.List(ctx, &CrudList{Call: call}) }, @@ -3337,6 +3367,12 @@ func AdaptCrud(t Crud) *rpc.Interface { Index: 0, Public: false, Params: []string{"name"}, + HTTP: &rpc.HTTPBinding{ + Verb: "DELETE", + Path: "/api/v1/apps/{name}", + Body: "", + PathParams: []string{"name"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.Destroy(ctx, &CrudDestroy{Call: call}) }, @@ -3347,6 +3383,12 @@ func AdaptCrud(t Crud) *rpc.Interface { Index: 0, Public: false, Params: []string{"app", "key", "value", "sensitive", "service"}, + HTTP: &rpc.HTTPBinding{ + Verb: "PUT", + Path: "/api/v1/apps/{app}/env/{key}", + Body: "*", + PathParams: []string{"app", "key"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.SetEnvVar(ctx, &CrudSetEnvVar{Call: call}) }, @@ -3357,6 +3399,12 @@ func AdaptCrud(t Crud) *rpc.Interface { Index: 0, Public: false, Params: []string{"app", "vars", "service"}, + HTTP: &rpc.HTTPBinding{ + Verb: "PUT", + Path: "/api/v1/apps/{app}/env", + Body: "*", + PathParams: []string{"app"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.SetEnvVars(ctx, &CrudSetEnvVars{Call: call}) }, @@ -3367,6 +3415,12 @@ func AdaptCrud(t Crud) *rpc.Interface { Index: 0, Public: false, Params: []string{"app", "vars", "service"}, + HTTP: &rpc.HTTPBinding{ + Verb: "POST", + Path: "/api/v1/apps/{app}/initial-env", + Body: "*", + PathParams: []string{"app"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.SetInitialEnvVars(ctx, &CrudSetInitialEnvVars{Call: call}) }, @@ -3377,6 +3431,15 @@ func AdaptCrud(t Crud) *rpc.Interface { Index: 0, Public: false, Params: []string{"app", "key", "service"}, + HTTP: &rpc.HTTPBinding{ + Verb: "DELETE", + Path: "/api/v1/apps/{app}/env/{key}", + Body: "", + PathParams: []string{"app", "key"}, + Query: []rpc.HTTPParam{ + {Name: "service", Kind: "string"}, + }, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.DeleteEnvVar(ctx, &CrudDeleteEnvVar{Call: call}) }, @@ -3387,6 +3450,12 @@ func AdaptCrud(t Crud) *rpc.Interface { Index: 0, Public: false, Params: []string{"app", "service"}, + HTTP: &rpc.HTTPBinding{ + Verb: "POST", + Path: "/api/v1/apps/{app}/restart", + Body: "*", + PathParams: []string{"app"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.Restart(ctx, &CrudRestart{Call: call}) }, @@ -4079,6 +4148,12 @@ func AdaptAppStatus(t AppStatus) *rpc.Interface { Index: 0, Public: false, Params: []string{"application"}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/api/v1/apps/{application}/status", + Body: "", + PathParams: []string{"application"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.AppInfo(ctx, &AppStatusAppInfo{Call: call}) }, @@ -4643,6 +4718,16 @@ func AdaptLogs(t Logs) *rpc.Interface { Index: 0, Public: false, Params: []string{"application", "from", "follow"}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/api/v1/apps/{application}/logs", + Body: "", + PathParams: []string{"application"}, + Query: []rpc.HTTPParam{ + {Name: "from", Kind: "timestamp"}, + {Name: "follow", Kind: "bool"}, + }, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.AppLogs(ctx, &LogsAppLogs{Call: call}) }, @@ -4653,6 +4738,16 @@ func AdaptLogs(t Logs) *rpc.Interface { Index: 0, Public: false, Params: []string{"sandbox", "from", "follow"}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/api/v1/sandboxes/{sandbox}/logs", + Body: "", + PathParams: []string{"sandbox"}, + Query: []rpc.HTTPParam{ + {Name: "from", Kind: "timestamp"}, + {Name: "follow", Kind: "bool"}, + }, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.SandboxLogs(ctx, &LogsSandboxLogs{Call: call}) }, @@ -5307,6 +5402,12 @@ func AdaptDisks(t Disks) *rpc.Interface { Index: 0, Public: false, Params: []string{"name", "capacity"}, + HTTP: &rpc.HTTPBinding{ + Verb: "POST", + Path: "/api/v1/disks", + Body: "*", + PathParams: []string{}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.New(ctx, &DisksNew{Call: call}) }, @@ -5317,6 +5418,12 @@ func AdaptDisks(t Disks) *rpc.Interface { Index: 0, Public: false, Params: []string{"id"}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/api/v1/disks/{id}", + Body: "", + PathParams: []string{"id"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.GetById(ctx, &DisksGetById{Call: call}) }, @@ -5327,6 +5434,12 @@ func AdaptDisks(t Disks) *rpc.Interface { Index: 0, Public: false, Params: []string{"name"}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/api/v1/disks/by-name/{name}", + Body: "", + PathParams: []string{"name"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.GetByName(ctx, &DisksGetByName{Call: call}) }, @@ -5337,6 +5450,12 @@ func AdaptDisks(t Disks) *rpc.Interface { Index: 0, Public: false, Params: []string{}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/api/v1/disks", + Body: "", + PathParams: []string{}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.List(ctx, &DisksList{Call: call}) }, @@ -5347,6 +5466,12 @@ func AdaptDisks(t Disks) *rpc.Interface { Index: 0, Public: false, Params: []string{"id"}, + HTTP: &rpc.HTTPBinding{ + Verb: "DELETE", + Path: "/api/v1/disks/{id}", + Body: "", + PathParams: []string{"id"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.Delete(ctx, &DisksDelete{Call: call}) }, @@ -5983,6 +6108,12 @@ func AdaptAddons(t Addons) *rpc.Interface { Index: 0, Public: false, Params: []string{"name", "addon", "variant", "app", "version"}, + HTTP: &rpc.HTTPBinding{ + Verb: "POST", + Path: "/api/v1/apps/{app}/addons", + Body: "*", + PathParams: []string{"app"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.CreateInstance(ctx, &AddonsCreateInstance{Call: call}) }, @@ -5993,6 +6124,12 @@ func AdaptAddons(t Addons) *rpc.Interface { Index: 0, Public: false, Params: []string{"app"}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/api/v1/apps/{app}/addons", + Body: "", + PathParams: []string{"app"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.ListInstances(ctx, &AddonsListInstances{Call: call}) }, @@ -6003,6 +6140,12 @@ func AdaptAddons(t Addons) *rpc.Interface { Index: 0, Public: false, Params: []string{"app", "name", "credential"}, + HTTP: &rpc.HTTPBinding{ + Verb: "POST", + Path: "/api/v1/apps/{app}/addons/{name}/rotate", + Body: "*", + PathParams: []string{"app", "name"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.RotateCredential(ctx, &AddonsRotateCredential{Call: call}) }, @@ -6013,6 +6156,12 @@ func AdaptAddons(t Addons) *rpc.Interface { Index: 0, Public: false, Params: []string{"app", "name"}, + HTTP: &rpc.HTTPBinding{ + Verb: "DELETE", + Path: "/api/v1/apps/{app}/addons/{name}", + Body: "", + PathParams: []string{"app", "name"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.DeleteInstance(ctx, &AddonsDeleteInstance{Call: call}) }, diff --git a/api/app/rpc.yml b/api/app/rpc.yml index ea387a691..59f23fc14 100644 --- a/api/app/rpc.yml +++ b/api/app/rpc.yml @@ -410,8 +410,13 @@ types: interfaces: - name: Crud + http: + prefix: /api/v1 methods: - name: new + http: + post: /apps + body: "*" parameters: - name: name type: string @@ -419,6 +424,9 @@ interfaces: - name: id type: string - name: setConfiguration + http: + put: /apps/{app}/config + body: "*" parameters: - name: app type: string @@ -430,6 +438,8 @@ interfaces: - name: versionShortId type: string - name: getConfiguration + http: + get: /apps/{app}/config parameters: - name: app type: string @@ -441,21 +451,31 @@ interfaces: - name: versionShortId type: string - name: setHost + http: + put: /apps/{app}/host + body: "*" parameters: - name: app type: string - name: host type: string - name: list + http: + get: /apps results: - name: apps type: list element: AppInfo - name: destroy + http: + delete: /apps/{name} parameters: - name: name type: string - name: setEnvVar + http: + put: /apps/{app}/env/{key} + body: "*" parameters: - name: app type: string @@ -474,6 +494,9 @@ interfaces: - name: versionShortId type: string - name: setEnvVars + http: + put: /apps/{app}/env + body: "*" parameters: - name: app type: string @@ -504,10 +527,15 @@ interfaces: - name: service type: string doc: "Optional service name for per-service env vars. Empty means global." + http: + post: /apps/{app}/initial-env + body: "*" results: - name: configVersionId type: string - name: deleteEnvVar + http: + delete: /apps/{app}/env/{key} parameters: - name: app type: string @@ -525,6 +553,9 @@ interfaces: type: string doc: "The source of the deleted variable (config or manual)" - name: restart + http: + post: /apps/{app}/restart + body: "*" parameters: - name: app type: string @@ -545,8 +576,12 @@ interfaces: type: UserInfo - name: AppStatus + http: + prefix: /api/v1 methods: - name: appInfo + http: + get: /apps/{application}/status parameters: - name: application type: string @@ -555,8 +590,12 @@ interfaces: type: ApplicationStatus - name: Logs + http: + prefix: /api/v1 methods: - name: appLogs + http: + get: /apps/{application}/logs parameters: - name: application type: string @@ -569,6 +608,8 @@ interfaces: type: list element: LogEntry - name: sandboxLogs + http: + get: /sandboxes/{sandbox}/logs parameters: - name: sandbox type: string @@ -608,8 +649,13 @@ interfaces: doc: "Optional upper time bound; when unset, reads up to now" - name: Disks + http: + prefix: /api/v1 methods: - name: new + http: + post: /disks + body: "*" parameters: - name: name type: string @@ -619,6 +665,8 @@ interfaces: - name: id type: string - name: getById + http: + get: /disks/{id} parameters: - name: id type: string @@ -626,6 +674,8 @@ interfaces: - name: config type: DiskConfig - name: getByName + http: + get: /disks/by-name/{name} parameters: - name: name type: string @@ -633,18 +683,27 @@ interfaces: - name: config type: DiskConfig - name: list + http: + get: /disks results: - name: disks type: list element: DiskConfig - name: delete + http: + delete: /disks/{id} parameters: - name: id type: string - name: Addons + http: + prefix: /api/v1 methods: - name: createInstance + http: + post: /apps/{app}/addons + body: "*" parameters: - name: name type: string @@ -661,6 +720,8 @@ interfaces: type: string - name: listInstances + http: + get: /apps/{app}/addons parameters: - name: app type: string @@ -670,6 +731,9 @@ interfaces: element: AddonInstance - name: rotateCredential + http: + post: /apps/{app}/addons/{name}/rotate + body: "*" parameters: - name: app type: string @@ -682,6 +746,8 @@ interfaces: type: string - name: deleteInstance + http: + delete: /apps/{app}/addons/{name} parameters: - name: app type: string diff --git a/api/metric/metric_v1alpha/rpc.gen.go b/api/metric/metric_v1alpha/rpc.gen.go index 3074c56a9..ca1884a10 100644 --- a/api/metric/metric_v1alpha/rpc.gen.go +++ b/api/metric/metric_v1alpha/rpc.gen.go @@ -315,6 +315,12 @@ func AdaptSandboxMetrics(t SandboxMetrics) *rpc.Interface { Index: 0, Public: false, Params: []string{"sandbox"}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/snapshot/{sandbox}", + Body: "", + PathParams: []string{"sandbox"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.Snapshot(ctx, &SandboxMetricsSnapshot{Call: call}) }, diff --git a/pkg/rpc/example/rpc.go b/pkg/rpc/example/rpc.go index aa7456000..1e19c1892 100644 --- a/pkg/rpc/example/rpc.go +++ b/pkg/rpc/example/rpc.go @@ -366,6 +366,12 @@ func AdaptMeter(t Meter) *rpc.Interface { Index: 0, Public: false, Params: []string{"name"}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/api/v1/meters/{name}/temperature", + Body: "", + PathParams: []string{"name"}, + }, Handler: func(ctx context.Context, call rpc.Call) error { return t.ReadTemperature(ctx, &MeterReadTemperature{Call: call}) }, diff --git a/pkg/rpc/example/rpc.yml b/pkg/rpc/example/rpc.yml index 4ea6bbce1..5dabb6f71 100644 --- a/pkg/rpc/example/rpc.yml +++ b/pkg/rpc/example/rpc.yml @@ -31,9 +31,13 @@ types: interfaces: - name: Meter + http: + prefix: /api/v1 methods: - name: readTemperature index: 0 + http: + get: /meters/{name}/temperature parameters: - name: name type: string diff --git a/pkg/rpc/generator.go b/pkg/rpc/generator.go index d2e775a88..53ba8f248 100644 --- a/pkg/rpc/generator.go +++ b/pkg/rpc/generator.go @@ -2018,6 +2018,32 @@ func (g *Generator) generateInterfaces(f *j.File) error { g.Lit(p.Name) } }) + if verb, bare, ok := httpBinding(i, m); ok { + params := httpPathParams(bare) + query := httpQueryParams(m, params) + g.Line().Id("HTTP").Op(":").Op("&").Qual(rpc, "HTTPBinding").ValuesFunc(func(g *j.Group) { + g.Line().Id("Verb").Op(":").Lit(verb) + g.Line().Id("Path").Op(":").Lit(bare) + g.Line().Id("Body").Op(":").Lit(m.HTTP.effectiveBody()) + g.Line().Id("PathParams").Op(":").Index().String().ValuesFunc(func(g *j.Group) { + for _, p := range params { + g.Lit(p) + } + }) + if len(query) > 0 { + g.Line().Id("Query").Op(":").Index().Qual(rpc, "HTTPParam").ValuesFunc(func(g *j.Group) { + for _, q := range query { + g.Line().Values( + j.Id("Name").Op(":").Lit(q.Name), + j.Id("Kind").Op(":").Lit(q.Kind), + ) + } + g.Line() + }) + } + g.Line() + }) + } g.Line().Id("Handler").Op(":").Func().Params( j.Id("ctx").Qual("context", "Context"), j.Id("call").Qual(rpc, "Call"), @@ -2321,6 +2347,13 @@ type DescInterface struct { Method []*DescMethods `yaml:"methods"` Generic []string `yaml:"generic,omitempty"` Constraints []string `yaml:"constraints,omitempty"` + HTTP *DescHTTPIface `yaml:"http,omitempty"` +} + +// DescHTTPIface holds interface-level REST configuration from the IDL http: block. +type DescHTTPIface struct { + // Prefix is prepended to every method's path template (e.g. /api/v1). + Prefix string `yaml:"prefix,omitempty"` } type DescMethods struct { @@ -2331,7 +2364,187 @@ type DescMethods struct { // Public marks this method as accessible without TLS client certificate authentication. // Public methods still require capability-level auth (Ed25519 signatures) but allow // unauthenticated callers (e.g., for registration flows where the client doesn't have certs yet). - Public bool `yaml:"public,omitempty"` + Public bool `yaml:"public,omitempty"` + HTTP *DescHTTPMethod `yaml:"http,omitempty"` +} + +// DescHTTPMethod holds method-level REST configuration from the IDL http: block. +// It accepts two YAML shapes (see UnmarshalYAML): a compact scalar +// "VERB /path/template" for the common case, or a mapping with an explicit verb +// key plus an optional body: override. Exactly one verb field ends up set. +type DescHTTPMethod struct { + Get string `yaml:"get,omitempty"` + Post string `yaml:"post,omitempty"` + Put string `yaml:"put,omitempty"` + Delete string `yaml:"delete,omitempty"` + Patch string `yaml:"patch,omitempty"` + // Body designates request-body binding: "*" binds the whole JSON body onto + // the method args; "" means no body and params come from the path and query + // string. When unset, effectiveBody defaults it from the verb. + Body string `yaml:"body,omitempty"` +} + +// UnmarshalYAML accepts either the compact scalar form ("POST /apps") or the +// mapping form ({post: /apps, body: "*"}). The compact form keeps the common +// case terse and is backward compatible with the pre-existing http: convention +// in the IDL. +func (m *DescHTTPMethod) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + parts := strings.Fields(value.Value) + if len(parts) != 2 { + return fmt.Errorf("http: expected \"VERB /path\", got %q", value.Value) + } + verb, path := strings.ToUpper(parts[0]), parts[1] + switch verb { + case "GET": + m.Get = path + case "POST": + m.Post = path + case "PUT": + m.Put = path + case "DELETE": + m.Delete = path + case "PATCH": + m.Patch = path + default: + return fmt.Errorf("http: unknown verb %q", verb) + } + return nil + } + + // Mapping form. Alias the type to avoid recursing into this method. + type raw DescHTTPMethod + var r raw + if err := value.Decode(&r); err != nil { + return err + } + *m = DescHTTPMethod(r) + return nil +} + +// verbPath returns the HTTP verb and the bare path template (without any +// interface prefix) for this method's binding, or ok=false when unset. +func (m *DescHTTPMethod) verbPath() (verb, path string, ok bool) { + switch { + case m.Get != "": + return "GET", m.Get, true + case m.Post != "": + return "POST", m.Post, true + case m.Put != "": + return "PUT", m.Put, true + case m.Delete != "": + return "DELETE", m.Delete, true + case m.Patch != "": + return "PATCH", m.Patch, true + default: + return "", "", false + } +} + +// effectiveBody resolves the request-body binding, defaulting from the verb when +// unset: body-carrying verbs (POST/PUT/PATCH) bind the whole JSON body, while +// GET/DELETE carry inputs in the path and query string. An explicit body: in +// the IDL always wins. +func (m *DescHTTPMethod) effectiveBody() string { + if m.Body != "" { + return m.Body + } + switch verb, _, _ := m.verbPath(); verb { + case "POST", "PUT", "PATCH": + return "*" + default: + return "" + } +} + +// httpBinding resolves a method's REST binding, returning the HTTP verb and the +// full path template with the interface prefix applied. ok is false when the +// method carries no http: annotation. +func httpBinding(i *DescInterface, m *DescMethods) (verb, path string, ok bool) { + if m.HTTP == nil { + return "", "", false + } + + verb, bare, ok := m.HTTP.verbPath() + if !ok { + return "", "", false + } + + if i.HTTP != nil && i.HTTP.Prefix != "" { + bare = strings.TrimRight(i.HTTP.Prefix, "/") + "/" + strings.TrimLeft(bare, "/") + } + + return verb, bare, true +} + +// httpQueryParam pairs a parameter name with the REST coercion kind of its type. +type httpQueryParam struct { + Name string + Kind string +} + +// httpQueryParams returns the parameters bound from the query string: every +// parameter that is not a path wildcard, but only for bodyless bindings. When a +// method carries a request body, its non-path params ride in the JSON body, so +// there is nothing to bind from the query. +func httpQueryParams(m *DescMethods, pathParams []string) []httpQueryParam { + if m.HTTP == nil || m.HTTP.effectiveBody() != "" { + return nil + } + + inPath := make(map[string]struct{}, len(pathParams)) + for _, p := range pathParams { + inPath[p] = struct{}{} + } + + var query []httpQueryParam + for _, p := range m.Parameters { + if _, ok := inPath[p.Name]; ok { + continue + } + query = append(query, httpQueryParam{Name: p.Name, Kind: httpParamKind(p.Type)}) + } + return query +} + +// httpParamKind maps an IDL scalar type name onto the coercion kind the REST +// gateway uses to turn a raw query string into typed JSON. Non-scalar types +// (messages, lists, maps, interfaces) fall back to "string", which for query +// binding means the raw value is passed through as a JSON string. +func httpParamKind(typeName string) string { + switch typeName { + case "bool": + return "bool" + case "int", "int8", "int16", "int32", "int64": + return "int" + case "uint", "uint8", "uint16", "uint32", "uint64", "byte": + return "uint" + case "float32", "float64": + return "float" + case "standard.Timestamp": + return "timestamp" + default: + return "string" + } +} + +// httpPathParams extracts the wildcard names ({name}) from a path template, in +// order of appearance. +func httpPathParams(path string) []string { + var params []string + for { + open := strings.IndexByte(path, '{') + if open == -1 { + break + } + close := strings.IndexByte(path[open:], '}') + if close == -1 { + break + } + params = append(params, path[open+1:open+close]) + path = path[open+close+1:] + } + return params } type DescParamater struct { diff --git a/pkg/rpc/generator_test.go b/pkg/rpc/generator_test.go index 0d5cbcc27..b182896b5 100644 --- a/pkg/rpc/generator_test.go +++ b/pkg/rpc/generator_test.go @@ -133,4 +133,22 @@ func TestGenerator(t *testing.T) { r.Equal(string(data), output) }) + + t.Run("can generate code for an interface with http annotations", func(t *testing.T) { + r := require.New(t) + + g, err := NewGenerator() + r.NoError(err) + + err = g.Read("testdata/rest.yml") + r.NoError(err) + + output, err := g.Generate("rest") + r.NoError(err) + + data, err := os.ReadFile("testdata/rest.go") + r.NoError(err) + + r.Equal(string(data), output) + }) } diff --git a/pkg/rpc/rest.go b/pkg/rpc/rest.go new file mode 100644 index 000000000..542d8aaf4 --- /dev/null +++ b/pkg/rpc/rest.go @@ -0,0 +1,259 @@ +package rpc + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "time" +) + +// RESTGateway mounts an RPC interface's HTTP-annotated methods onto a +// http.ServeMux as a plain REST/JSON API. It reuses the exact handler +// implementations registered via AdaptXxx — a REST call is dispatched through +// the same rpc.Method.Handler that a CBOR-over-QUIC call would be, backed by a +// restCall that sources arguments from the HTTP request and captures results as +// JSON. +// +// Only methods carrying an http: annotation in the IDL (i.e. with a non-nil +// Method.HTTP) are exposed; capability-returning and streaming methods must be +// left un-annotated. + +// RegisterREST mounts every HTTP-annotated method of iface onto mux. Route +// patterns use Go 1.22 method+wildcard syntax ("GET /api/v1/apps/{app}"), so a +// single mux can host multiple interfaces as long as their paths do not +// conflict. +func RegisterREST(mux *http.ServeMux, iface *Interface) { + for _, m := range iface.Methods() { + if m.HTTP == nil { + continue + } + mux.HandleFunc(m.HTTP.Verb+" "+m.HTTP.Path, restHandler(m)) + } +} + +func restHandler(m Method) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + call := &restCall{r: r, binding: m.HTTP} + + if err := m.Handler(r.Context(), call); err != nil { + writeRESTError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + + if call.results == nil { + w.WriteHeader(http.StatusOK) + return + } + + body, err := json.Marshal(call.results) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + } +} + +// restCall adapts an HTTP request to the rpc.Call interface. It supports the +// data-plane of a call (arguments in, results out); the capability operations +// panic because capabilities cannot be expressed over plain REST. +type restCall struct { + r *http.Request + binding *HTTPBinding + results any +} + +var _ Call = (*restCall)(nil) + +// Args assembles a JSON object from the request body, path wildcards, and query +// string, then unmarshals it into v (a generated *XxxArgs, which implements +// json.Unmarshaler over its json-tagged data struct). Precedence, lowest to +// highest: request body, query string, path parameters. +func (c *restCall) Args(v any) { + fields := map[string]json.RawMessage{} + + if c.binding.Body == "*" && c.r.Body != nil { + // Ignore decode errors: an empty or absent body just leaves fields + // unset, matching the "all optional" shape of generated args. + _ = json.NewDecoder(c.r.Body).Decode(&fields) + } + + // Query params carry the non-path inputs of a bodyless verb (GET/DELETE). + // Each is coerced from its raw string into typed JSON per its declared kind, + // so bools, numbers, and timestamps land in their typed fields rather than + // as strings. A value that fails to parse for its kind is simply omitted. + query := c.r.URL.Query() + for _, p := range c.binding.Query { + raw := query.Get(p.Name) + if raw == "" { + continue + } + if val, ok := coerceQueryValue(p.Kind, raw); ok { + fields[p.Name] = val + } + } + + // Path parameters win: they are the canonical addressing of the resource. + // Path segments are always strings. + for _, name := range c.binding.PathParams { + if val := c.r.PathValue(name); val != "" { + fields[name] = jsonString(val) + } + } + + data, err := json.Marshal(fields) + if err != nil { + return + } + + _ = json.Unmarshal(data, v) +} + +// coerceQueryValue turns a raw query-string value into typed JSON according to +// the parameter's kind. It returns ok=false when the value does not parse for +// the kind, so the caller can leave the field unset rather than injecting a +// malformed value. +func coerceQueryValue(kind, raw string) (json.RawMessage, bool) { + switch kind { + case "bool": + b, err := strconv.ParseBool(raw) + if err != nil { + return nil, false + } + return jsonMarshal(b) + case "int": + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil, false + } + return jsonMarshal(n) + case "uint": + n, err := strconv.ParseUint(raw, 10, 64) + if err != nil { + return nil, false + } + return jsonMarshal(n) + case "float": + f, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil, false + } + return jsonMarshal(f) + case "timestamp": + return coerceTimestamp(raw) + default: + return jsonString(raw), true + } +} + +// coerceTimestamp accepts an RFC3339 timestamp (with or without sub-second +// precision) or a bare unix-seconds integer and renders it as the JSON object +// shape of standard.Timestamp ({"seconds":N,"nanoseconds":M}), which the +// generated Timestamp.UnmarshalJSON consumes. +func coerceTimestamp(raw string) (json.RawMessage, bool) { + var t time.Time + if parsed, err := time.Parse(time.RFC3339Nano, raw); err == nil { + t = parsed + } else if secs, err := strconv.ParseInt(raw, 10, 64); err == nil { + t = time.Unix(secs, 0).UTC() + } else { + return nil, false + } + + return jsonMarshal(struct { + Seconds int64 `json:"seconds"` + Nanoseconds int32 `json:"nanoseconds"` + }{ + Seconds: t.Unix(), + Nanoseconds: int32(t.Nanosecond()), + }) +} + +func jsonMarshal(v any) (json.RawMessage, bool) { + b, err := json.Marshal(v) + if err != nil { + return nil, false + } + return b, true +} + +func (c *restCall) Results(v any) { + c.results = v +} + +// IsAuthenticated reports whether the request carried an authenticated +// identity. The REST gateway relies on the surrounding http.Handler stack (or a +// future auth middleware) to populate the identity in the request context, the +// same way handleCalls does for the RPC transport. +func (c *restCall) IsAuthenticated() bool { + return IdentityFromContext(c.r.Context()) != nil +} + +func (c *restCall) NewCapability(i *Interface) *Capability { + panic("rpc: capabilities are not supported over the REST gateway") +} + +func (c *restCall) NewClient(capa *Capability) Client { + panic("rpc: capabilities are not supported over the REST gateway") +} + +func jsonString(s string) json.RawMessage { + b, _ := json.Marshal(s) + return b +} + +// restErrorBody is the JSON shape returned for a failed REST call. +type restErrorBody struct { + Error string `json:"error"` + Code string `json:"code,omitempty"` + Category string `json:"category,omitempty"` +} + +func writeRESTError(w http.ResponseWriter, err error) { + body := restErrorBody{Error: err.Error()} + + if em, ok := err.(ErrorMessage); ok { + body.Error = em.ErrorMessage() + } + if ec, ok := err.(ErrorCode); ok { + body.Code = ec.ErrorCode() + } + if ec, ok := err.(ErrorCategory); ok { + body.Category = ec.ErrorCategory() + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(restStatusFor(err, body.Code)) + + data, mErr := json.Marshal(body) + if mErr != nil { + return + } + _, _ = w.Write(data) +} + +// restStatusFor maps an error's code onto an HTTP status. The codes mirror +// pkg/cond's error taxonomy (not-found, conflict, ...). +func restStatusFor(err error, code string) int { + switch code { + case "not-found": + return http.StatusNotFound + case "conflict": + return http.StatusConflict + case "validation": + return http.StatusBadRequest + } + + if errors.Is(err, ErrUnauthorized) { + return http.StatusForbidden + } + + return http.StatusInternalServerError +} diff --git a/pkg/rpc/rest_test.go b/pkg/rpc/rest_test.go new file mode 100644 index 000000000..53bcc8f47 --- /dev/null +++ b/pkg/rpc/rest_test.go @@ -0,0 +1,178 @@ +package rpc_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + "miren.dev/runtime/api/app/app_v1alpha" + "miren.dev/runtime/pkg/cond" + "miren.dev/runtime/pkg/rpc" + "miren.dev/runtime/pkg/rpc/example" + "miren.dev/runtime/pkg/rpc/standard" +) + +// restMeter is a Meter implementation for exercising the REST gateway. It knows +// a single meter ("room1") and returns a cond.NotFound for anything else. +type restMeter struct{} + +func (m *restMeter) ReadTemperature(ctx context.Context, call *example.MeterReadTemperature) error { + name := call.Args().Name() + if name != "room1" { + return cond.NotFound("meter", name) + } + + reading := new(example.Reading) + reading.SetMeter(name) + reading.SetTemperature(21.5) + call.Results().SetReading(reading) + return nil +} + +func (m *restMeter) GetSetter(ctx context.Context, call *example.MeterGetSetter) error { + panic("not used in REST tests") +} + +func TestRESTGateway(t *testing.T) { + newServer := func() *httptest.Server { + mux := http.NewServeMux() + rpc.RegisterREST(mux, example.AdaptMeter(&restMeter{})) + return httptest.NewServer(mux) + } + + t.Run("serves an annotated method as REST/JSON", func(t *testing.T) { + r := require.New(t) + + srv := newServer() + defer srv.Close() + + resp, err := http.Get(srv.URL + "/api/v1/meters/room1/temperature") + r.NoError(err) + defer resp.Body.Close() + + r.Equal(http.StatusOK, resp.StatusCode) + r.Equal("application/json", resp.Header.Get("Content-Type")) + + body, err := io.ReadAll(resp.Body) + r.NoError(err) + + var out struct { + Reading struct { + Meter string `json:"meter"` + Temperature float32 `json:"temperature"` + } `json:"reading"` + } + r.NoError(json.Unmarshal(body, &out)) + r.Equal("room1", out.Reading.Meter) + r.Equal(float32(21.5), out.Reading.Temperature) + }) + + t.Run("maps a not-found error onto HTTP 404 with a code", func(t *testing.T) { + r := require.New(t) + + srv := newServer() + defer srv.Close() + + resp, err := http.Get(srv.URL + "/api/v1/meters/basement/temperature") + r.NoError(err) + defer resp.Body.Close() + + r.Equal(http.StatusNotFound, resp.StatusCode) + + var out struct { + Error string `json:"error"` + Code string `json:"code"` + } + r.NoError(json.NewDecoder(resp.Body).Decode(&out)) + r.Equal("not-found", out.Code) + r.Contains(out.Error, "basement") + }) + + t.Run("un-annotated and unknown routes are not mounted", func(t *testing.T) { + r := require.New(t) + + srv := newServer() + defer srv.Close() + + // getSetter has no http: annotation, so nothing is mounted for it. + resp, err := http.Get(srv.URL + "/api/v1/meters/room1/setter") + r.NoError(err) + resp.Body.Close() + r.Equal(http.StatusNotFound, resp.StatusCode) + + // POST to a GET-only route: the mux rejects the method mismatch. + resp2, err := http.Post(srv.URL+"/api/v1/meters/room1/temperature", "application/json", nil) + r.NoError(err) + resp2.Body.Close() + r.Equal(http.StatusMethodNotAllowed, resp2.StatusCode) + }) +} + +// echoLogs is a Logs implementation that echoes the parsed query parameters +// back so a test can prove typed query binding (timestamp + bool) worked. Only +// the non-streaming AppLogs/SandboxLogs methods are REST-annotated; the +// streaming methods are never reached over the gateway. +type echoLogs struct{} + +func (echoLogs) AppLogs(ctx context.Context, state *app_v1alpha.LogsAppLogs) error { + args := state.Args() + + line := fmt.Sprintf("app=%s follow=%v hasFrom=%v", args.Application(), args.Follow(), args.HasFrom()) + + entry := new(app_v1alpha.LogEntry) + entry.SetLine(line) + if args.HasFrom() { + entry.SetTimestamp(args.From()) + } + state.Results().SetLogs([]*app_v1alpha.LogEntry{entry}) + return nil +} + +func (echoLogs) SandboxLogs(ctx context.Context, state *app_v1alpha.LogsSandboxLogs) error { + return cond.NotFound("sandbox", state.Args().Sandbox()) +} + +func (echoLogs) StreamLogs(ctx context.Context, state *app_v1alpha.LogsStreamLogs) error { + panic("streaming is not exposed over REST") +} + +func (echoLogs) StreamLogChunks(ctx context.Context, state *app_v1alpha.LogsStreamLogChunks) error { + panic("streaming is not exposed over REST") +} + +func TestRESTGatewayTypedQueryParams(t *testing.T) { + r := require.New(t) + + mux := http.NewServeMux() + rpc.RegisterREST(mux, app_v1alpha.AdaptLogs(echoLogs{})) + srv := httptest.NewServer(mux) + defer srv.Close() + + from := time.Date(2026, 7, 24, 10, 0, 0, 0, time.UTC) + + resp, err := http.Get(srv.URL + "/api/v1/apps/myapp/logs?from=" + from.Format(time.RFC3339) + "&follow=true") + r.NoError(err) + defer resp.Body.Close() + + r.Equal(http.StatusOK, resp.StatusCode) + + var out struct { + Logs []struct { + Line string `json:"line"` + Timestamp *standard.Timestamp `json:"timestamp"` + } `json:"logs"` + } + r.NoError(json.NewDecoder(resp.Body).Decode(&out)) + r.Len(out.Logs, 1) + + // follow=true parsed as a bool, from parsed as a real timestamp (not ""). + r.Equal("app=myapp follow=true hasFrom=true", out.Logs[0].Line) + r.NotNil(out.Logs[0].Timestamp) + r.True(from.Equal(standard.FromTimestamp(out.Logs[0].Timestamp))) +} diff --git a/pkg/rpc/server.go b/pkg/rpc/server.go index 3661d1ed0..3cb5f35ae 100644 --- a/pkg/rpc/server.go +++ b/pkg/rpc/server.go @@ -122,6 +122,39 @@ type Method struct { // understands a specific parameter (e.g. one added after the method first // shipped) instead of only whether the method exists. See HasMethodParam. Params []string + // HTTP describes how to expose this method over a plain HTTP/JSON REST API. + // It is nil unless the method carries an http: annotation in the IDL. The + // REST gateway (RegisterREST) only mounts routes for methods with a binding. + HTTP *HTTPBinding +} + +// HTTPBinding maps an RPC method onto an HTTP route for the REST gateway. It is +// populated by generated AdaptXxx code from the method's IDL http: annotation. +type HTTPBinding struct { + // Verb is the HTTP method (GET, POST, PUT, DELETE, PATCH). + Verb string + // Path is the fully-resolved route template, including any interface + // prefix, using Go 1.22 ServeMux wildcards (e.g. /api/v1/apps/{app}/config). + Path string + // Body designates where the request body maps: "*" binds the whole JSON + // body onto the args, "" means no body (params come from path/query). + Body string + // PathParams lists the wildcard names embedded in Path, in order. + PathParams []string + // Query lists the parameters bound from the URL query string, with the type + // info needed to coerce their string values into typed JSON. It is only + // populated for bodyless bindings (Body == ""); when a body is present the + // non-path params ride in the JSON body instead. + Query []HTTPParam +} + +// HTTPParam describes a single query-bound parameter for the REST gateway. +type HTTPParam struct { + // Name is the parameter (and query key) name. + Name string + // Kind selects how the raw string value is coerced into JSON: one of + // "string", "bool", "int", "uint", "float", or "timestamp". + Kind string } type HasRestoreState interface { @@ -145,6 +178,22 @@ func (i *Interface) Value() any { return i.value } +// Name returns the schema name of the interface (e.g. "Crud"). +func (i *Interface) Name() string { + return i.name +} + +// Methods returns the interface's methods. The order is unspecified. It exists +// so external packages (notably the REST gateway) can enumerate methods without +// access to the private method map. +func (i *Interface) Methods() []Method { + methods := make([]Method, 0, len(i.methods)) + for _, m := range i.methods { + methods = append(methods, m) + } + return methods +} + func (i *Interface) SetAroundContext(fn func(ctx context.Context, call Call) (context.Context, func())) { i.aroundContext = fn } diff --git a/pkg/rpc/testdata/rest.go b/pkg/rpc/testdata/rest.go new file mode 100644 index 000000000..516167e05 --- /dev/null +++ b/pkg/rpc/testdata/rest.go @@ -0,0 +1,821 @@ +package rest + +import ( + "context" + "encoding/json" + "slices" + + "github.com/fxamacker/cbor/v2" + rpc "miren.dev/runtime/pkg/rpc" +) + +type widgetData struct { + Id *string `cbor:"0,keyasint,omitempty" json:"id,omitempty"` + Size *int32 `cbor:"1,keyasint,omitempty" json:"size,omitempty"` +} + +type Widget struct { + data widgetData +} + +func (v *Widget) HasId() bool { + return v.data.Id != nil +} + +func (v *Widget) Id() string { + if v.data.Id == nil { + return "" + } + return *v.data.Id +} + +func (v *Widget) SetId(id string) { + v.data.Id = &id +} + +func (v *Widget) HasSize() bool { + return v.data.Size != nil +} + +func (v *Widget) Size() int32 { + if v.data.Size == nil { + return 0 + } + return *v.data.Size +} + +func (v *Widget) SetSize(size int32) { + v.data.Size = &size +} + +func (v *Widget) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *Widget) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *Widget) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *Widget) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type widgetsGetArgsData struct { + Id *string `cbor:"0,keyasint,omitempty" json:"id,omitempty"` +} + +type WidgetsGetArgs struct { + call rpc.Call + data widgetsGetArgsData +} + +func (v *WidgetsGetArgs) HasId() bool { + return v.data.Id != nil +} + +func (v *WidgetsGetArgs) Id() string { + if v.data.Id == nil { + return "" + } + return *v.data.Id +} + +func (v *WidgetsGetArgs) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *WidgetsGetArgs) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *WidgetsGetArgs) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *WidgetsGetArgs) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type widgetsGetResultsData struct { + Widget *Widget `cbor:"0,keyasint,omitempty" json:"widget,omitempty"` +} + +type WidgetsGetResults struct { + call rpc.Call + data widgetsGetResultsData +} + +func (v *WidgetsGetResults) SetWidget(widget *Widget) { + v.data.Widget = widget +} + +func (v *WidgetsGetResults) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *WidgetsGetResults) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *WidgetsGetResults) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *WidgetsGetResults) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type widgetsCreateArgsData struct { + Id *string `cbor:"0,keyasint,omitempty" json:"id,omitempty"` + Size *int32 `cbor:"1,keyasint,omitempty" json:"size,omitempty"` +} + +type WidgetsCreateArgs struct { + call rpc.Call + data widgetsCreateArgsData +} + +func (v *WidgetsCreateArgs) HasId() bool { + return v.data.Id != nil +} + +func (v *WidgetsCreateArgs) Id() string { + if v.data.Id == nil { + return "" + } + return *v.data.Id +} + +func (v *WidgetsCreateArgs) HasSize() bool { + return v.data.Size != nil +} + +func (v *WidgetsCreateArgs) Size() int32 { + if v.data.Size == nil { + return 0 + } + return *v.data.Size +} + +func (v *WidgetsCreateArgs) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *WidgetsCreateArgs) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *WidgetsCreateArgs) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *WidgetsCreateArgs) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type widgetsCreateResultsData struct { + Widget *Widget `cbor:"0,keyasint,omitempty" json:"widget,omitempty"` +} + +type WidgetsCreateResults struct { + call rpc.Call + data widgetsCreateResultsData +} + +func (v *WidgetsCreateResults) SetWidget(widget *Widget) { + v.data.Widget = widget +} + +func (v *WidgetsCreateResults) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *WidgetsCreateResults) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *WidgetsCreateResults) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *WidgetsCreateResults) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type widgetsDeleteArgsData struct { + Id *string `cbor:"0,keyasint,omitempty" json:"id,omitempty"` +} + +type WidgetsDeleteArgs struct { + call rpc.Call + data widgetsDeleteArgsData +} + +func (v *WidgetsDeleteArgs) HasId() bool { + return v.data.Id != nil +} + +func (v *WidgetsDeleteArgs) Id() string { + if v.data.Id == nil { + return "" + } + return *v.data.Id +} + +func (v *WidgetsDeleteArgs) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *WidgetsDeleteArgs) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *WidgetsDeleteArgs) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *WidgetsDeleteArgs) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type widgetsDeleteResultsData struct{} + +type WidgetsDeleteResults struct { + call rpc.Call + data widgetsDeleteResultsData +} + +func (v *WidgetsDeleteResults) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *WidgetsDeleteResults) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *WidgetsDeleteResults) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *WidgetsDeleteResults) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type widgetsSearchArgsData struct { + Prefix *string `cbor:"0,keyasint,omitempty" json:"prefix,omitempty"` + Limit *int32 `cbor:"1,keyasint,omitempty" json:"limit,omitempty"` + Active *bool `cbor:"2,keyasint,omitempty" json:"active,omitempty"` +} + +type WidgetsSearchArgs struct { + call rpc.Call + data widgetsSearchArgsData +} + +func (v *WidgetsSearchArgs) HasPrefix() bool { + return v.data.Prefix != nil +} + +func (v *WidgetsSearchArgs) Prefix() string { + if v.data.Prefix == nil { + return "" + } + return *v.data.Prefix +} + +func (v *WidgetsSearchArgs) HasLimit() bool { + return v.data.Limit != nil +} + +func (v *WidgetsSearchArgs) Limit() int32 { + if v.data.Limit == nil { + return 0 + } + return *v.data.Limit +} + +func (v *WidgetsSearchArgs) HasActive() bool { + return v.data.Active != nil +} + +func (v *WidgetsSearchArgs) Active() bool { + if v.data.Active == nil { + return false + } + return *v.data.Active +} + +func (v *WidgetsSearchArgs) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *WidgetsSearchArgs) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *WidgetsSearchArgs) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *WidgetsSearchArgs) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type widgetsSearchResultsData struct { + Widgets *[]*Widget `cbor:"0,keyasint,omitempty" json:"widgets,omitempty"` +} + +type WidgetsSearchResults struct { + call rpc.Call + data widgetsSearchResultsData +} + +func (v *WidgetsSearchResults) SetWidgets(widgets []*Widget) { + x := slices.Clone(widgets) + v.data.Widgets = &x +} + +func (v *WidgetsSearchResults) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *WidgetsSearchResults) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *WidgetsSearchResults) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *WidgetsSearchResults) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type widgetsInternalOnlyArgsData struct { + Id *string `cbor:"0,keyasint,omitempty" json:"id,omitempty"` +} + +type WidgetsInternalOnlyArgs struct { + call rpc.Call + data widgetsInternalOnlyArgsData +} + +func (v *WidgetsInternalOnlyArgs) HasId() bool { + return v.data.Id != nil +} + +func (v *WidgetsInternalOnlyArgs) Id() string { + if v.data.Id == nil { + return "" + } + return *v.data.Id +} + +func (v *WidgetsInternalOnlyArgs) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *WidgetsInternalOnlyArgs) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *WidgetsInternalOnlyArgs) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *WidgetsInternalOnlyArgs) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type widgetsInternalOnlyResultsData struct { + Ok *bool `cbor:"0,keyasint,omitempty" json:"ok,omitempty"` +} + +type WidgetsInternalOnlyResults struct { + call rpc.Call + data widgetsInternalOnlyResultsData +} + +func (v *WidgetsInternalOnlyResults) SetOk(ok bool) { + v.data.Ok = &ok +} + +func (v *WidgetsInternalOnlyResults) MarshalCBOR() ([]byte, error) { + return cbor.Marshal(v.data) +} + +func (v *WidgetsInternalOnlyResults) UnmarshalCBOR(data []byte) error { + return cbor.Unmarshal(data, &v.data) +} + +func (v *WidgetsInternalOnlyResults) MarshalJSON() ([]byte, error) { + return json.Marshal(v.data) +} + +func (v *WidgetsInternalOnlyResults) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &v.data) +} + +type WidgetsGet struct { + rpc.Call + args WidgetsGetArgs + results WidgetsGetResults +} + +func (t *WidgetsGet) Args() *WidgetsGetArgs { + args := &t.args + if args.call != nil { + return args + } + args.call = t.Call + t.Call.Args(args) + return args +} + +func (t *WidgetsGet) Results() *WidgetsGetResults { + results := &t.results + if results.call != nil { + return results + } + results.call = t.Call + t.Call.Results(results) + return results +} + +type WidgetsCreate struct { + rpc.Call + args WidgetsCreateArgs + results WidgetsCreateResults +} + +func (t *WidgetsCreate) Args() *WidgetsCreateArgs { + args := &t.args + if args.call != nil { + return args + } + args.call = t.Call + t.Call.Args(args) + return args +} + +func (t *WidgetsCreate) Results() *WidgetsCreateResults { + results := &t.results + if results.call != nil { + return results + } + results.call = t.Call + t.Call.Results(results) + return results +} + +type WidgetsDelete struct { + rpc.Call + args WidgetsDeleteArgs + results WidgetsDeleteResults +} + +func (t *WidgetsDelete) Args() *WidgetsDeleteArgs { + args := &t.args + if args.call != nil { + return args + } + args.call = t.Call + t.Call.Args(args) + return args +} + +func (t *WidgetsDelete) Results() *WidgetsDeleteResults { + results := &t.results + if results.call != nil { + return results + } + results.call = t.Call + t.Call.Results(results) + return results +} + +type WidgetsSearch struct { + rpc.Call + args WidgetsSearchArgs + results WidgetsSearchResults +} + +func (t *WidgetsSearch) Args() *WidgetsSearchArgs { + args := &t.args + if args.call != nil { + return args + } + args.call = t.Call + t.Call.Args(args) + return args +} + +func (t *WidgetsSearch) Results() *WidgetsSearchResults { + results := &t.results + if results.call != nil { + return results + } + results.call = t.Call + t.Call.Results(results) + return results +} + +type WidgetsInternalOnly struct { + rpc.Call + args WidgetsInternalOnlyArgs + results WidgetsInternalOnlyResults +} + +func (t *WidgetsInternalOnly) Args() *WidgetsInternalOnlyArgs { + args := &t.args + if args.call != nil { + return args + } + args.call = t.Call + t.Call.Args(args) + return args +} + +func (t *WidgetsInternalOnly) Results() *WidgetsInternalOnlyResults { + results := &t.results + if results.call != nil { + return results + } + results.call = t.Call + t.Call.Results(results) + return results +} + +type Widgets interface { + Get(ctx context.Context, state *WidgetsGet) error + Create(ctx context.Context, state *WidgetsCreate) error + Delete(ctx context.Context, state *WidgetsDelete) error + Search(ctx context.Context, state *WidgetsSearch) error + InternalOnly(ctx context.Context, state *WidgetsInternalOnly) error +} + +type reexportWidgets struct { + client rpc.Client +} + +func (reexportWidgets) Get(ctx context.Context, state *WidgetsGet) error { + panic("not implemented") +} + +func (reexportWidgets) Create(ctx context.Context, state *WidgetsCreate) error { + panic("not implemented") +} + +func (reexportWidgets) Delete(ctx context.Context, state *WidgetsDelete) error { + panic("not implemented") +} + +func (reexportWidgets) Search(ctx context.Context, state *WidgetsSearch) error { + panic("not implemented") +} + +func (reexportWidgets) InternalOnly(ctx context.Context, state *WidgetsInternalOnly) error { + panic("not implemented") +} + +func (t reexportWidgets) CapabilityClient() rpc.Client { + return t.client +} + +func AdaptWidgets(t Widgets) *rpc.Interface { + methods := []rpc.Method{ + { + Name: "get", + InterfaceName: "Widgets", + Index: 0, + Public: false, + Params: []string{"id"}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/api/v1/widgets/{id}", + Body: "", + PathParams: []string{"id"}, + }, + Handler: func(ctx context.Context, call rpc.Call) error { + return t.Get(ctx, &WidgetsGet{Call: call}) + }, + }, + { + Name: "create", + InterfaceName: "Widgets", + Index: 1, + Public: false, + Params: []string{"id", "size"}, + HTTP: &rpc.HTTPBinding{ + Verb: "POST", + Path: "/api/v1/widgets", + Body: "*", + PathParams: []string{}, + }, + Handler: func(ctx context.Context, call rpc.Call) error { + return t.Create(ctx, &WidgetsCreate{Call: call}) + }, + }, + { + Name: "delete", + InterfaceName: "Widgets", + Index: 2, + Public: false, + Params: []string{"id"}, + HTTP: &rpc.HTTPBinding{ + Verb: "DELETE", + Path: "/api/v1/widgets/{id}", + Body: "", + PathParams: []string{"id"}, + }, + Handler: func(ctx context.Context, call rpc.Call) error { + return t.Delete(ctx, &WidgetsDelete{Call: call}) + }, + }, + { + Name: "search", + InterfaceName: "Widgets", + Index: 4, + Public: false, + Params: []string{"prefix", "limit", "active"}, + HTTP: &rpc.HTTPBinding{ + Verb: "GET", + Path: "/api/v1/widgets", + Body: "", + PathParams: []string{}, + Query: []rpc.HTTPParam{ + {Name: "prefix", Kind: "string"}, + {Name: "limit", Kind: "int"}, + {Name: "active", Kind: "bool"}, + }, + }, + Handler: func(ctx context.Context, call rpc.Call) error { + return t.Search(ctx, &WidgetsSearch{Call: call}) + }, + }, + { + Name: "internalOnly", + InterfaceName: "Widgets", + Index: 3, + Public: false, + Params: []string{"id"}, + Handler: func(ctx context.Context, call rpc.Call) error { + return t.InternalOnly(ctx, &WidgetsInternalOnly{Call: call}) + }, + }, + } + + return rpc.NewInterface(methods, t) +} + +type WidgetsClient struct { + rpc.Client +} + +func NewWidgetsClient(client rpc.Client) *WidgetsClient { + return &WidgetsClient{Client: client} +} + +func (c WidgetsClient) Export() Widgets { + return reexportWidgets{client: c.Client} +} + +type WidgetsClientGetResults struct { + client rpc.Client + data widgetsGetResultsData +} + +func (v *WidgetsClientGetResults) HasWidget() bool { + return v.data.Widget != nil +} + +func (v *WidgetsClientGetResults) Widget() *Widget { + return v.data.Widget +} + +func (v WidgetsClient) Get(ctx context.Context, id string) (*WidgetsClientGetResults, error) { + args := WidgetsGetArgs{} + args.data.Id = &id + + var ret widgetsGetResultsData + + err := v.Call(ctx, "get", &args, &ret) + if err != nil { + return nil, err + } + + return &WidgetsClientGetResults{client: v.Client, data: ret}, nil +} + +type WidgetsClientCreateResults struct { + client rpc.Client + data widgetsCreateResultsData +} + +func (v *WidgetsClientCreateResults) HasWidget() bool { + return v.data.Widget != nil +} + +func (v *WidgetsClientCreateResults) Widget() *Widget { + return v.data.Widget +} + +func (v WidgetsClient) Create(ctx context.Context, id string, size int32) (*WidgetsClientCreateResults, error) { + args := WidgetsCreateArgs{} + args.data.Id = &id + args.data.Size = &size + + var ret widgetsCreateResultsData + + err := v.Call(ctx, "create", &args, &ret) + if err != nil { + return nil, err + } + + return &WidgetsClientCreateResults{client: v.Client, data: ret}, nil +} + +type WidgetsClientDeleteResults struct { + client rpc.Client + data widgetsDeleteResultsData +} + +func (v WidgetsClient) Delete(ctx context.Context, id string) (*WidgetsClientDeleteResults, error) { + args := WidgetsDeleteArgs{} + args.data.Id = &id + + var ret widgetsDeleteResultsData + + err := v.Call(ctx, "delete", &args, &ret) + if err != nil { + return nil, err + } + + return &WidgetsClientDeleteResults{client: v.Client, data: ret}, nil +} + +type WidgetsClientSearchResults struct { + client rpc.Client + data widgetsSearchResultsData +} + +func (v *WidgetsClientSearchResults) HasWidgets() bool { + return v.data.Widgets != nil +} + +func (v *WidgetsClientSearchResults) Widgets() []*Widget { + if v.data.Widgets == nil { + return nil + } + return *v.data.Widgets +} + +func (v WidgetsClient) Search(ctx context.Context, prefix string, limit int32, active bool) (*WidgetsClientSearchResults, error) { + args := WidgetsSearchArgs{} + args.data.Prefix = &prefix + args.data.Limit = &limit + args.data.Active = &active + + var ret widgetsSearchResultsData + + err := v.Call(ctx, "search", &args, &ret) + if err != nil { + return nil, err + } + + return &WidgetsClientSearchResults{client: v.Client, data: ret}, nil +} + +type WidgetsClientInternalOnlyResults struct { + client rpc.Client + data widgetsInternalOnlyResultsData +} + +func (v *WidgetsClientInternalOnlyResults) HasOk() bool { + return v.data.Ok != nil +} + +func (v *WidgetsClientInternalOnlyResults) Ok() bool { + if v.data.Ok == nil { + return false + } + return *v.data.Ok +} + +func (v WidgetsClient) InternalOnly(ctx context.Context, id string) (*WidgetsClientInternalOnlyResults, error) { + args := WidgetsInternalOnlyArgs{} + args.data.Id = &id + + var ret widgetsInternalOnlyResultsData + + err := v.Call(ctx, "internalOnly", &args, &ret) + if err != nil { + return nil, err + } + + return &WidgetsClientInternalOnlyResults{client: v.Client, data: ret}, nil +} diff --git a/pkg/rpc/testdata/rest.yml b/pkg/rpc/testdata/rest.yml new file mode 100644 index 000000000..9c3c7deca --- /dev/null +++ b/pkg/rpc/testdata/rest.yml @@ -0,0 +1,70 @@ +apiVersion: miren.dev/rpc/v1 +kind: IDL +types: + - type: Widget + fields: + - name: id + type: string + index: 0 + - name: size + type: int32 + index: 1 + +interfaces: + - name: Widgets + http: + prefix: /api/v1 + methods: + - name: get + index: 0 + http: + get: /widgets/{id} + parameters: + - name: id + type: string + results: + - name: widget + type: Widget + - name: create + index: 1 + http: + post: /widgets + body: "*" + parameters: + - name: id + type: string + - name: size + type: int32 + results: + - name: widget + type: Widget + - name: delete + index: 2 + http: + delete: /widgets/{id} + parameters: + - name: id + type: string + - name: search + index: 4 + http: + get: /widgets + parameters: + - name: prefix + type: string + - name: limit + type: int32 + - name: active + type: bool + results: + - name: widgets + type: list + element: Widget + - name: internalOnly + index: 3 + parameters: + - name: id + type: string + results: + - name: ok + type: bool