Skip to content

Add a REST/JSON gateway for the RPC framework#978

Draft
evanphx wants to merge 1 commit into
mainfrom
exp/rest-rpc
Draft

Add a REST/JSON gateway for the RPC framework#978
evanphx wants to merge 1 commit into
mainfrom
exp/rest-rpc

Conversation

@evanphx

@evanphx evanphx commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Our RPC framework speaks CBOR over QUIC with a capability model. That's great for internal service-to-service calls, but it gives us nothing to hand a user who just wants a plain HTTP/JSON API. This is foundation work toward a simple Miren Runtime API we can expose to users: annotate methods in the IDL with an HTTP verb and path, and get a real http.Server serving a full REST surface backed by the exact same handler implementations. No second implementation, no hand-written HTTP glue.

The approach mirrors grpc-gateway but stays in-process. A method opts in with an http: block in its rpc.yml; the generator threads that binding into the generated rpc.Method, and RegisterREST mounts routes on a stdlib ServeMux. Each request runs through the existing Method.Handler via a restCall that sources arguments from the request (JSON body, path wildcards, query string) and captures results as JSON. The generated arg/result structs already carry json tags, so the round-trip is essentially free, and reusing the handler means REST and RPC can never drift apart.

Query parameters are type-aware. The Logs reads were the forcing function here: from is a timestamp and follow is a bool, neither of which survives being bound as a raw string. So the generator emits each query param's kind, and the runtime coerces bools, numbers, and RFC3339 timestamps into their typed fields. Errors map onto HTTP status codes through the existing ErrorCode taxonomy (not-found to 404, conflict to 409, and so on).

The app API is annotated as the first real consumer: Crud, AppStatus, Disks, Addons, and the non-streaming Logs reads all get REST routes under /api/v1. Streaming and capability-returning methods are intentionally left RPC-only, since neither maps cleanly onto request/response HTTP.

A couple of things deliberately left for follow-ups: nothing wires the gateway onto a listener in miren server yet, so the REST surface is exercised by tests but not yet served in production. And response timestamps still serialize as {seconds, nanoseconds}; making them RFC3339 on the way out (symmetric with query input) means teaching the generator to give standard.Timestamp a friendlier JSON codec, which touches every JSON response and felt like its own change.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds HTTP route metadata to RPC definitions and generated method adapters, including verbs, paths, request bodies, path parameters, and typed query parameters. It introduces public HTTP binding descriptors and a REST gateway that dispatches annotated methods, decodes request data, serializes JSON responses, and maps errors to HTTP statuses. Application and example RPC interfaces gain REST routes. New widget fixtures and tests validate generation, route mounting, JSON responses, error handling, method restrictions, and typed query parsing.


Comment @coderabbitai help to get the list of available commands.

@miren-code-agent miren-code-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🍪 biscuit: ⚠️ ready with caveats — auto-review, non-blocking

This is a draft PR adding a REST/JSON gateway on top of the existing RPC framework. The design is sound: IDL annotations flow through the generator into HTTPBinding structs embedded in each Method, and RegisterREST uses those structs to mount routes on a plain http.ServeMux. I read through all of the new code carefully.

What works well

The Args precedence order (body → query → path) is the right call — path params canonically win over anything else. The coerceQueryValue switch is clear and covers the expected scalar kinds. The two-step round-trip (json.Marshal(fields)json.Unmarshal(data, v)) is a little roundabout, but it's correct and lets the generated UnmarshalJSON on args structs do its own work without a custom parser. The generator changes are clean — httpBinding, httpPathParams, and httpQueryParams are small, focused, and easy to follow, and the test that compares generated output against the golden testdata/rest.go gives confidence the codegen is stable.

Concrete concerns

  1. No auth enforcement in restHandler (rest.go, lines 41–60). The existing RPC transport refuses unauthenticated calls to non-Public methods. restHandler dispatches directly to m.Handler without checking m.Public or call.IsAuthenticated(). If a method is non-public and no auth middleware wraps the mux, the REST gateway will answer calls that the QUIC transport would reject. The doc comment says "a future auth middleware" will handle this, but that's an open security gap at the point of mounting. At minimum the code should enforce m.Public here, and ideally return HTTP 401/403 for non-public methods missing an identity, so callers get the same semantics over both transports.

  2. Path prefix joining can produce double slashes (generator.go, httpBinding function):

    bare = strings.TrimRight(i.HTTP.Prefix, "/") + "/" + strings.TrimLeft(bare, "/")

    When the interface prefix is /api/v1 and the method path already starts with /, you get /api/v1/apps. Fine. But if the method path has no leading slash (which the IDL files here don't), the logic is still correct. The real risk is the opposite: if someone writes a prefix without a trailing slash and a method path without a leading slash, the middle "/" handles it. This is robust. Not a bug, just worth knowing.

  3. restCall.Results captures only the last assignment (rest.go, line 187–189). If a handler stores intermediate results before the final state (unusual but possible), earlier writes are silently dropped. This matches the QUIC path's semantics so it's consistent, but worth documenting if this ever supports streaming results.

  4. json.Marshal(call.results) after w.Header().Set but before w.WriteHeader (rest.go, lines 46–60). The Content-Type header is set at line 46, then the marshal happens. If json.Marshal fails at line 54, http.Error is called — but http.Error sets its own Content-Type and writes a status. That works fine in practice because WriteHeader hasn't been called yet at that point, so the second Content-Type will win. However, if marshal succeeds, w.WriteHeader(http.StatusOK) is explicit at line 59. Calling w.Header().Set at line 46 before w.WriteHeader is correct, but a reader has to trace the two paths carefully to confirm it. Moving the Content-Type assignment to just before the write would make the success path unambiguous.

  5. Silent body-decode error (rest.go, lines 82–86). Ignoring JSON decode errors for a body-binding (Body == "*") is intentional and the comment explains it. That's a reasonable choice for an "all fields optional" schema. But a malformed Content-Type: application/json body that sends {"name": } (invalid JSON) is silently treated as "no body", and the args will be empty rather than rejected. If the IDL has any mandatory-feeling fields, callers get a confusing empty-args invocation rather than a 400. Consider returning 400 when the body is non-empty and fails to parse, at least when the Content-Type is application/json.

The security point (item 1) is the only thing I'd call a merge blocker for a non-draft. Since this is still a draft, it's appropriate to raise these now rather than after the PR graduates. Everything else is refinement.


🍪 full review note · comment /biscuit review to run biscuit again.

Comment thread pkg/rpc/rest.go

call := &restCall{r: r, binding: m.HTTP}

if err := m.Handler(r.Context(), call); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth gap: restHandler dispatches directly to m.Handler without checking m.Public or whether the caller is authenticated. The RPC transport enforces auth on non-public methods; the REST gateway currently does not. If this mux is mounted without an external auth middleware, non-public methods will be callable unauthenticated. At minimum, add a check like if !m.Public && !call.IsAuthenticated() { writeRESTError(w, ErrUnauthorized); return } before invoking the handler.

🤖 Prompt for AI Agents
In pkg/rpc/rest.go inside the restHandler closure,
before the call to m.Handler (around line 41), add
an authentication check. If the method is not
public (m.Public == false) and the caller is not
authenticated (call.IsAuthenticated() == false),
call writeRESTError(w, ErrUnauthorized) and return
early. This mirrors the auth enforcement the QUIC
transport already does for non-public methods.

Comment thread pkg/rpc/rest.go
fields := map[string]json.RawMessage{}

if c.binding.Body == "*" && c.r.Body != nil {
// Ignore decode errors: an empty or absent body just leaves fields

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent body parse failure: when Body == "*", a malformed JSON body (e.g. {"name": }) is silently ignored and args are left empty. For callers sending Content-Type: application/json, consider returning HTTP 400 when the body is non-empty and fails to decode, so they learn about the bad payload rather than receiving a confusing empty-args response.

🤖 Prompt for AI Agents
In pkg/rpc/rest.go in the restCall.Args method
around line 82-86, when c.binding.Body == "*" and
c.r.Body is non-nil, the JSON decode error is
currently silently discarded. Change this so that
if the request Content-Type is application/json
and the body is non-empty but fails to decode into
the fields map, the function signals an error to
the caller. One approach: check
r.Header.Get("Content-Type") and r.ContentLength
!= 0, and if the decode fails, write an HTTP 400
error via the response writer. Because Args
doesn't have access to the ResponseWriter
directly, consider having Args return an error or
having restHandler check the decode result before
proceeding.

Our RPC framework speaks CBOR over QUIC with a capability model, which
is great for internal service-to-service calls but gives us nothing to
hand a user who just wants a plain HTTP/JSON API. This lays the
foundation for a simple Miren Runtime API: annotate methods in the IDL
with an HTTP verb and path, and get a real http.Server that exposes a
full REST surface backed by the exact same handler implementations. No
second implementation, no hand-written HTTP glue.

The approach mirrors grpc-gateway but stays in-process. A method opts
in with an http: annotation, either the compact "POST /apps" form (also
backward compatible with the pre-existing convention in the IDL) or a
mapping when it needs an explicit body binding. The generator threads
that binding into the generated rpc.Method, and RegisterREST mounts
routes on a stdlib ServeMux. Each request runs through the existing
Method.Handler via a restCall that sources args from the request (JSON
body, path wildcards, query string) and captures results as JSON. The
generated arg/result structs already carry json tags, so round-tripping
is free.

Query params are type-aware: the generator emits each query param's
kind so the runtime can coerce bools, numbers, and RFC3339 timestamps
into their typed fields rather than binding everything as a string.
Errors map onto HTTP status via the existing ErrorCode taxonomy.

The app API (Crud, AppStatus, Disks, Addons, and the non-streaming
Logs reads) is annotated as the first real consumer. Streaming and
capability-returning methods are intentionally left RPC-only.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/rpc/generator.go`:
- Around line 2021-2046: Add generation-time HTTP annotation validation before
code generation, covering each method’s `DescHTTPMethod`: reject multiple
non-empty verb fields, reject `Body` values other than empty or `"*"`, and
ensure every wildcard from `verbPath()`/`httpPathParams()` matches a declared
`m.Parameters` entry. Propagate these errors through the existing
`Generate()`/`generateInterfaces()` error paths with clear messages, while
leaving the HTTP binding generation unchanged for valid annotations.
- Around line 2404-2422: Update httpBinding to reject annotated
capability/streaming methods before returning a binding, using the method shape
information available on DescMethods. Return ok=false for unsupported methods so
generation skips them and the REST gateway never attempts to create a
capability/client; preserve existing verb, path, and interface-prefix handling
for supported methods.

In `@pkg/rpc/rest.go`:
- Around line 219-240: Update writeRESTError to use errors.As when extracting
ErrorMessage, ErrorCode, and ErrorCategory, so wrapped errors are traversed and
their metadata populates restErrorBody before restStatusFor is called. Preserve
the existing direct-error behavior and ensure the errors package is imported.
- Around line 35-117: Update restHandler to decode the request body before
invoking m.Handler when the binding body is "*", treating io.EOF as an empty
body and returning HTTP 400 for malformed JSON. Pass the parsed fields into
restCall so restCall.Args no longer decodes the body or silently discards body
errors; also propagate final unmarshal/type-mismatch errors through restCall for
a 400 response, and bound the body size before decoding.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6f884afd-e048-4dd8-beb7-736135289993

📥 Commits

Reviewing files that changed from the base of the PR and between 7f611d5 and 11f50d5.

📒 Files selected for processing (11)
  • api/app/app_v1alpha/rpc.gen.go
  • api/app/rpc.yml
  • pkg/rpc/example/rpc.go
  • pkg/rpc/example/rpc.yml
  • pkg/rpc/generator.go
  • pkg/rpc/generator_test.go
  • pkg/rpc/rest.go
  • pkg/rpc/rest_test.go
  • pkg/rpc/server.go
  • pkg/rpc/testdata/rest.go
  • pkg/rpc/testdata/rest.yml

Comment thread pkg/rpc/generator.go
Comment on lines +2021 to +2046
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.Body)
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()
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add generation-time validation for HTTP annotation configs (verb ambiguity, body value, path-param names).

Three related gaps in the new HTTP-annotation parsing, none of which produce a generation-time error:

  1. verbPath() (2387-2402) silently prefers the first non-empty verb field (Get before Post/Put/... ) if a method's IDL mistakenly sets two of them — the second is dropped with no warning.
  2. httpQueryParams() (2434-2452) treats any non-empty Body as "has a body" (m.HTTP.Body != ""), but pkg/rpc/rest.go's restCall.Args only decodes the request body when Body is exactly "*" (c.binding.Body == "*"). A typo such as body: "yes" compiles fine, suppresses query-param generation here, yet is never decoded in rest.go — the resulting endpoint silently loses both its query and body binding.
  3. httpPathParams() (2475-2491) extracts {name} wildcards purely from the path template text, with no check that each name matches a declared parameter in m.Parameters. A typo in the path template (e.g. {App} vs parameter app) produces a PathParams entry with no corresponding JSON field on the generated Args struct; at runtime the path value is silently dropped by json.Unmarshal's unknown-field handling instead of erroring.

Since Generate()/generateInterfaces() already return error, an upfront validation pass over g.Interfaces/i.Method (before codegen starts) could reject all three cases with a clear message.

♻️ Sketch of validation checks to add
func (m *DescHTTPMethod) validate(params []*DescParamater) error {
	verbs := 0
	for _, v := range []string{m.Get, m.Post, m.Put, m.Delete, m.Patch} {
		if v != "" {
			verbs++
		}
	}
	if verbs > 1 {
		return errors.New("http: only one verb field may be set")
	}
	if m.Body != "" && m.Body != "*" {
		return fmt.Errorf("http: body must be \"\" or \"*\", got %q", m.Body)
	}

	_, bare, _ := m.verbPath()
	declared := make(map[string]bool, len(params))
	for _, p := range params {
		declared[p.Name] = true
	}
	for _, name := range httpPathParams(bare) {
		if !declared[name] {
			return fmt.Errorf("http: path param %q has no matching parameter", name)
		}
	}
	return nil
}

Also applies to: 2371-2402, 2434-2452, 2475-2491

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/rpc/generator.go` around lines 2021 - 2046, Add generation-time HTTP
annotation validation before code generation, covering each method’s
`DescHTTPMethod`: reject multiple non-empty verb fields, reject `Body` values
other than empty or `"*"`, and ensure every wildcard from
`verbPath()`/`httpPathParams()` matches a declared `m.Parameters` entry.
Propagate these errors through the existing `Generate()`/`generateInterfaces()`
error paths with clear messages, while leaving the HTTP binding generation
unchanged for valid annotations.

Comment thread pkg/rpc/generator.go
Comment on lines +2404 to +2422
// 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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'Capability|stream\.' pkg/rpc/generator.go
rg -n 'IsCapability|isStream|Stream\b' pkg/rpc/*.go

Repository: mirendev/runtime

Length of output: 2416


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== httpBinding and surrounding generator code ==\n'
rg -n -C 4 'func httpBinding|verbPath\(\)|HTTP != nil|NewCapability|NewClient' pkg/rpc/generator.go pkg/rpc/rest.go

printf '\n== method-shape helpers in generator ==\n'
rg -n -C 3 'Capability|Stream|isCapability|isStream|Result|Param|HTTP' pkg/rpc/generator.go

printf '\n== rest call constructors ==\n'
rg -n -C 6 'func \(.*NewCapability|func \(.*NewClient|panic\(' pkg/rpc/rest.go

Repository: mirendev/runtime

Length of output: 47630


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 6 'httpBinding\(' pkg/rpc/generator.go
rg -n -C 4 'NewCapability|NewClient|Capability|Stream' pkg/rpc/generator.go | sed -n '1,220p'

Repository: mirendev/runtime

Length of output: 5691


Reject http: on capability/streaming methods. httpBinding() applies to every annotated method, and the REST gateway panics if it has to create a capability/client. Add a shape check before emitting the binding so these methods fail at generation time instead of mid-request.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/rpc/generator.go` around lines 2404 - 2422, Update httpBinding to reject
annotated capability/streaming methods before returning a binding, using the
method shape information available on DescMethods. Return ok=false for
unsupported methods so generation skips them and the REST gateway never attempts
to create a capability/client; preserve existing verb, path, and
interface-prefix handling for supported methods.

Comment thread pkg/rpc/rest.go
Comment on lines +35 to +117
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Args() silently swallows malformed-body and type-mismatch errors, turning bad requests into silent no-ops.

Both the body decode (line 85) and the final json.Unmarshal(data, v) (line 116) discard all errors unconditionally. The doc comment justifies this for an empty/absent body, but it also swallows genuine JSON syntax errors — a malformed POST body to e.g. /api/v1/widgets would silently run the handler with default/empty args and return 200, instead of a 400. Since rpc.Call.Args(v any) has no error return, the cleanest fix is to parse the body in restHandler (which can still write an HTTP response) before invoking m.Handler, distinguishing io.EOF (fine) from real parse errors (400).

Also, once this gateway is wired to a real listener, consider bounding the body size (e.g. http.MaxBytesReader) before decoding into map[string]json.RawMessage to avoid unbounded memory use from an oversized request.

🐛 Proposed fix: parse the body before dispatching, reject malformed JSON with 400
+import (
+	"io"
+)
+
 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}
+		var body map[string]json.RawMessage
+		if m.HTTP.Body == "*" {
+			if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
+				http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
+				return
+			}
+		}
+
+		call := &restCall{r: r, binding: m.HTTP, body: body}
 ...

 type restCall struct {
 	r       *http.Request
 	binding *HTTPBinding
+	body    map[string]json.RawMessage
 	results any
 }
 ...
 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)
-	}
+	for k, v := range c.body {
+		fields[k] = v
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/rpc/rest.go` around lines 35 - 117, Update restHandler to decode the
request body before invoking m.Handler when the binding body is "*", treating
io.EOF as an empty body and returning HTTP 400 for malformed JSON. Pass the
parsed fields into restCall so restCall.Args no longer decodes the body or
silently discards body errors; also propagate final unmarshal/type-mismatch
errors through restCall for a 400 response, and bound the body size before
decoding.

Comment thread pkg/rpc/rest.go
Comment on lines +219 to +240
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use errors.As instead of raw type assertions to see through wrapped errors.

errors.Is(err, ErrUnauthorized) at line 254 correctly unwraps, but the ErrorMessage/ErrorCode/ErrorCategory checks here use direct type assertions on err. If any handler wraps a cond.* error with additional context (e.g. fmt.Errorf("...: %w", cond.NotFound(...)) — a common Go pattern), the assertion fails silently, body.Code stays empty, and restStatusFor falls back to 500 instead of the correct 404/409/400. This directly undercuts the PR's goal of mapping ErrorCode values onto HTTP statuses.

🐛 Proposed fix
-	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()
-	}
+	var em ErrorMessage
+	if errors.As(err, &em) {
+		body.Error = em.ErrorMessage()
+	}
+	var ec ErrorCode
+	if errors.As(err, &ec) {
+		body.Code = ec.ErrorCode()
+	}
+	var ecat ErrorCategory
+	if errors.As(err, &ecat) {
+		body.Category = ecat.ErrorCategory()
+	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
}
func writeRESTError(w http.ResponseWriter, err error) {
body := restErrorBody{Error: err.Error()}
var em ErrorMessage
if errors.As(err, &em) {
body.Error = em.ErrorMessage()
}
var ec ErrorCode
if errors.As(err, &ec) {
body.Code = ec.ErrorCode()
}
var ecat ErrorCategory
if errors.As(err, &ecat) {
body.Category = ecat.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)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/rpc/rest.go` around lines 219 - 240, Update writeRESTError to use
errors.As when extracting ErrorMessage, ErrorCode, and ErrorCategory, so wrapped
errors are traversed and their metadata populates restErrorBody before
restStatusFor is called. Preserve the existing direct-error behavior and ensure
the errors package is imported.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/rpc/rest.go`:
- Around line 93-100: Update the query-binding loop in the REST request handler
to distinguish absent parameters from explicitly provided empty values using the
query map, and return HTTP 400 when a declared parameter is present but
coerceQueryValue fails. Preserve omission only for parameters that are truly
absent, while retaining successful coercion and field assignment.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f04b266f-bf3d-45af-a09f-d424b9b0119a

📥 Commits

Reviewing files that changed from the base of the PR and between 11f50d5 and 98dc211.

📒 Files selected for processing (12)
  • api/app/app_v1alpha/rpc.gen.go
  • api/app/rpc.yml
  • api/metric/metric_v1alpha/rpc.gen.go
  • pkg/rpc/example/rpc.go
  • pkg/rpc/example/rpc.yml
  • pkg/rpc/generator.go
  • pkg/rpc/generator_test.go
  • pkg/rpc/rest.go
  • pkg/rpc/rest_test.go
  • pkg/rpc/server.go
  • pkg/rpc/testdata/rest.go
  • pkg/rpc/testdata/rest.yml
🚧 Files skipped from review as they are similar to previous changes (9)
  • pkg/rpc/generator_test.go
  • pkg/rpc/testdata/rest.yml
  • pkg/rpc/example/rpc.yml
  • pkg/rpc/server.go
  • pkg/rpc/generator.go
  • api/app/app_v1alpha/rpc.gen.go
  • api/app/rpc.yml
  • pkg/rpc/example/rpc.go
  • pkg/rpc/testdata/rest.go

Comment thread pkg/rpc/rest.go
Comment on lines +93 to +100
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject invalid typed query parameters instead of silently dropping them.

For example, ?follow=tru is omitted and the handler runs with its default value. Return HTTP 400 when a declared query parameter is present but cannot be coerced; distinguish an absent parameter from an explicitly empty one via the query map.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/rpc/rest.go` around lines 93 - 100, Update the query-binding loop in the
REST request handler to distinguish absent parameters from explicitly provided
empty values using the query map, and return HTTP 400 when a declared parameter
is present but coerceQueryValue fails. Preserve omission only for parameters
that are truly absent, while retaining successful coercion and field assignment.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant