feat(modkit-contract): add PoC contract stack for REST and gRPC#4084
feat(modkit-contract): add PoC contract stack for REST and gRPC#4084MikeFalcon77 wants to merge 9 commits into
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 Walkthrough📝 Walkthrough✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
gears/system/gear-orchestrator/src/server.rs (1)
89-93:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winInconsistent error handling in
list_instances.
list_instancesusesStatus::internal(e.to_string())which leaks the raw error message to clients, while all other methods use the newmap_directory_errorhelper that sanitizes errors to avoid exposing internal details (DB connection strings, file paths, etc.).Proposed fix
let instances = self .api .list_instances(&gear_name) .await - .map_err(|e| Status::internal(e.to_string()))?; + .map_err(|e| map_directory_error(&e))?;🤖 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 `@gears/system/gear-orchestrator/src/server.rs` around lines 89 - 93, The list_instances call currently maps errors with Status::internal(e.to_string()) and may leak internal details; change the error mapping to use the existing map_directory_error helper so errors are sanitized consistently—update the .map_err on self.api.list_instances(&gear_name).await to call map_directory_error(e) (or the appropriate signature) so list_instances uses the same sanitized error path as other methods.libs/toolkit-canonical-errors/src/problem.rs (1)
599-643:⚠️ Potential issue | 🟠 Major | ⚡ Quick winExpose
error_codeanderror_domainin the OpenAPI schema.
Problemnow serializes these two discriminator fields, but theutoipa::PartialSchemaimplementation still omits them. That leaves generated OpenAPI docs/clients out of sync with the actual wire payload for typed contract errors.Suggested schema patch
ObjectBuilder::new() .property( "type", ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)), ) @@ .property( "trace_id", ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)), ) + .property( + "error_code", + ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)), + ) + .property( + "error_domain", + ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)), + ) .property( "context", ObjectBuilder::new().schema_type(SchemaType::Type(Type::Object)), ) .required("context") .description(Some( - "RFC 9457 problem+json. `context` varies by error category.", + "RFC 9457 problem+json. `context` varies by error category; typed contract errors may also include `error_code` and `error_domain`.", )) .into()🤖 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 `@libs/toolkit-canonical-errors/src/problem.rs` around lines 599 - 643, The PartialSchema for Problem is missing the serialized discriminator fields error_code and error_domain; update the impl utoipa::PartialSchema for Problem (the schema builder chain) to add .property("error_code", ObjectBuilder::new().schema_type(SchemaType::Type(Type::String))).required("error_code") and .property("error_domain", ObjectBuilder::new().schema_type(SchemaType::Type(Type::String))).required("error_domain") (or mark as optional if your serde exposes them as optional) so the generated OpenAPI matches the actual Problem wire payload produced by the Problem type.
🟠 Major comments (29)
examples/toolkit/api-contracts/api-contracts/src/module.rs-63-70 (1)
63-70:⚠️ Potential issue | 🟠 Major | ⚡ Quick winBind the REST server to a local implementation, not the hub-selected client.
Line 69 resolves
PaymentApithroughClientHub, but this module’s own docs say that wiring is transport-selected. If config picksrest, these server handlers can end up calling the REST client instead of the local service, which turns the endpoint into a proxy and can recurse back into itself.register_restshould attach a local implementation for serving requests, independent of consumer-side transport selection.🤖 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 `@examples/toolkit/api-contracts/api-contracts/src/module.rs` around lines 63 - 70, register_rest erroneously binds the server handlers to the client selected via ctx.client_hub().get::<dyn PaymentApi>(), which can make the server proxy to its own REST client; instead obtain or instantiate the module's local server implementation and pass that into routes::register_routes. Replace the call to ctx.client_hub().get::<dyn PaymentApi>() with the local service (e.g., instantiate LocalPaymentApi / PaymentApiImpl or retrieve via a local-service accessor like ctx.local_service::<dyn PaymentApi>()), then pass that concrete/local impl to routes::register_routes(router, openapi, local_impl) ensuring types/lifetimes match so the server endpoints call the local implementation rather than the hub-selected client.examples/toolkit/api-contracts/api-contracts/src/api/grpc.rs-140-145 (1)
140-145:⚠️ Potential issue | 🟠 Major | ⚡ Quick winReject missing credentials here to match the REST transport contract.
Line 144 turns a missing
authorizationheader intoSecurityContext::anonymous(), but the REST surface registers the same operations as authenticated inexamples/toolkit/api-contracts/api-contracts/src/api/rest/routes.rs. That makes gRPC the weaker path and can permit unauthenticated calls unless every domain entrypoint re-checks auth itself. Please reject missing credentials here the same way malformed credentials are rejected, or make anonymous access an explicit per-method policy instead of the default.🤖 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 `@examples/toolkit/api-contracts/api-contracts/src/api/grpc.rs` around lines 140 - 145, In require_security_context, do not map AuthOutcome::Anonymous to SecurityContext::anonymous(); instead treat missing credentials as an authentication failure like malformed credentials: change the AuthOutcome::Anonymous match arm to return an Err(Status::unauthenticated(...)) (or equivalent gRPC unauthenticated Status) with a clear message so gRPC rejects missing Authorization headers to match the REST contract; keep AuthOutcome::Authenticated(ctx) returning Ok(ctx) and ensure classify_auth is still used to distinguish malformed vs missing credentials if you want different error messages.examples/toolkit/api-contracts/api-contracts/src/client/local.rs-86-94 (1)
86-94:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftDon't bypass the policy stack for
list_payments.This method returns the raw domain stream without invoking
self.policies, so streaming calls skip the same tracing/metrics/policy hooks that unary methods go through. That makesPaymentLocalClientobservably inconsistent across methods.🤖 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 `@examples/toolkit/api-contracts/api-contracts/src/client/local.rs` around lines 86 - 94, list_payments in PaymentLocalClient currently returns the raw stream from self.service.list_payments, bypassing self.policies and therefore skipping tracing/metrics/policy hooks; change it to construct the stream via self.service.list_payments(&ctx, &filter) and then pass that stream through the policy stack (e.g., call the policies wrapper used by other methods such as self.policies.apply_stream or the equivalent method your PolicyStack exposes) so the returned PaymentStream<PaymentSummary> has the same per-call hooks applied; ensure you use the same SecurityContext and return the wrapped stream instead of the raw one.libs/toolkit-contract-protogen/src/lib.rs-1166-1176 (1)
1166-1176:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDetect
oneoffield-name collisions aftersnake_casenormalization.Two externally-tagged variants whose discriminator keys normalize to the same proto field name will both be emitted into the same
oneofwith the same identifier. That produces invalid.protoand can also alias lockfile assignments across distinct variants.🤖 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 `@libs/toolkit-contract-protogen/src/lib.rs` around lines 1166 - 1176, The code currently converts discriminator keys to snake_case (snake_variant_name) and blindly assigns a field number, which lets two distinct keys that normalize to the same snake name collide; update the logic before calling lock.messages.entry(...).assign(...) to detect duplicates by checking whether the target schema's assigned names already contain snake_variant_name (you can inspect the existing OneofVariant entries in variants for the same schema or add a lookup on lock.messages for assigned names), and if a duplicate is found, fail fast with a clear error (or return a Result::Err) identifying the original key and the colliding key; keep the symbols to change: snake_variant_name, key, field_number, lock.messages, schema_name, variants, and OneofVariant.libs/toolkit-contract-protogen/src/lib.rs-895-917 (1)
895-917:⚠️ Potential issue | 🟠 Major | ⚡ Quick winReject heterogeneous
anyOfinstead of treating it as nullable$ref.This branch accepts any
anyOfthat contains one$ref, even if there are additional non-null alternatives. For example,[$ref, null, string]currently collapses tooptional <ref>, which drops part of the schema instead of raisingProtoGenFeature::AnyOf.🤖 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 `@libs/toolkit-contract-protogen/src/lib.rs` around lines 895 - 917, The anyOf handling in lib.rs currently treats any anyOf with a $ref and a null as an optional ref, which silently drops other alternatives; modify the branch that processes schema.get("anyOf") so it only collapses to an optional $ref when every non-null entry is a $ref and there is exactly one distinct $ref target; otherwise return Err(ProtoGenFeature::AnyOf). Concretely, in the loop that inspects entries, track the count of non-null non-$ref alternatives (and multiple distinct $ref targets) and if you encounter any non-$ref non-null type or more than one distinct $ref, short-circuit to Err(ProtoGenFeature::AnyOf) instead of mapping to ProtoFieldShape; keep the existing use of ref_to_name, queue.push(target), and the returned ProtoFieldShape when the strict single-$ref-or-null condition is satisfied.libs/toolkit-contract-protogen/src/lockfile.rs-74-79 (1)
74-79:⚠️ Potential issue | 🟠 Major | ⚡ Quick winReject unsupported lockfile versions on load.
versionis documented as the incompatible-format guard, butload()never checks it. A future lockfile with a different shape will deserialize as far as it can and then be used to assign wire numbers, which can silently corrupt reservations instead of failing fast.Suggested fix
pub fn load(path: &Path) -> Result<Self, LockfileError> { match std::fs::read_to_string(path) { - Ok(text) => toml::from_str(&text).map_err(LockfileError::Parse), + Ok(text) => { + let lock: Self = toml::from_str(&text).map_err(LockfileError::Parse)?; + if lock.version != LOCKFILE_VERSION { + return Err(LockfileError::UnsupportedVersion { + found: lock.version, + expected: LOCKFILE_VERSION, + }); + } + Ok(lock) + } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::empty()), Err(e) => Err(LockfileError::Io(e)), } }🤖 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 `@libs/toolkit-contract-protogen/src/lockfile.rs` around lines 74 - 79, The load function currently deserializes without validating the lockfile version; update load to parse into Self (as now), then check the deserialized object's version field against the expected/current version constant and return an Err(LockfileError::...) for mismatches instead of proceeding (preserve the existing NotFound handling that returns Self::empty()); if LockfileError lacks an appropriate variant add one (e.g., UnsupportedVersion) to surface the rejection.libs/toolkit-contract-protogen/src/lib.rs-705-715 (1)
705-715:⚠️ Potential issue | 🟠 Major | ⚡ Quick winPersist the synthetic
entriesfield through the lockfile.The top-level
additionalPropertieswrapper hard-codesentries = 1instead of allocating throughlock.messages[schema_name]. If that schema later evolves from a map wrapper into a normal object, field1is immediately reusable becauseentrieswas never recorded or tombstoned, which breaks the wire-compat guarantee this crate is introducing.Suggested fix
+ let field_number = lock + .messages + .entry(schema_name.to_owned()) + .or_default() + .assign("entries"); messages.insert( schema_name.to_owned(), MessageDef { fields: vec![MessageField { name: "entries".to_owned(), proto_type: format!("map<string, {}>", value_shape.proto_type), repeated: false, optional: false, is_map: true, - field_number: 1, + field_number, }], oneof: None, ..MessageDef::default() }, );🤖 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 `@libs/toolkit-contract-protogen/src/lib.rs` around lines 705 - 715, The synthetic top-level map wrapper is being created with a hard-coded field_number: 1 for the MessageField "entries" (in the messages.insert branch that constructs MessageDef/MessageField), which must instead be allocated and persisted via the lock (lock.messages[schema_name]) so the field is tombstoned/recorded; change the code that builds MessageDef for schema_name to consult or allocate the next available field id in lock.messages[schema_name] (or record the newly assigned id into lock.messages[schema_name]) and use that value for MessageField.field_number rather than the constant 1, ensuring subsequent schema changes reuse or respect the recorded/tombstoned id.examples/toolkit/api-contracts/api-contracts-sdk/src/models.rs-72-91 (1)
72-91:⚠️ Potential issue | 🟠 Major | ⚡ Quick winAdd a DTO variant for the proto zero enum value.
The committed
.protoincludesPAYMENT_STATUS_UNSPECIFIED = 0, but this SDK enum cannot represent it. That makes the gRPC surface lossy forChargeResponse,Invoice, andPaymentSummary: an omitted/default enum value from a peer decodes as0on the wire, yet there is no corresponding Rust variant to bridge to. Please add anUnspecified/Unknownvariant here, or make the wire fields optional before shipping the gRPC contract.🤖 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 `@examples/toolkit/api-contracts/api-contracts-sdk/src/models.rs` around lines 72 - 91, The enum PaymentStatus cannot represent the proto zero value (PAYMENT_STATUS_UNSPECIFIED = 0); add an explicit Unspecified (or Unknown) variant to PaymentStatus (e.g., Unspecified) and make it the #[default] so zero maps correctly, keeping serde(rename_all = "snake_case") and proto bridge attributes intact; update any references in ChargeResponse, Invoice, and PaymentSummary to accept the new variant (no field type changes needed) so gRPC enum values decode losslessly.examples/toolkit/api-contracts/api-contracts-sdk/examples/gen_grpc_proto.rs-8-8 (1)
8-8:⚠️ Potential issue | 🟠 MajorFeature-gate
gen_grpc_protodocs/imports withgrpc-client.
gen_grpc_proto.rsimportscf_api_contracts_sdk::grpc::payment_api_grpc_bindingunconditionally, butcf-api-contracts-sdkonly exposespub mod grpc/payment_api_grpc_bindingunder#[cfg(feature = "grpc-client")], whileexamples/toolkit/api-contracts/api-contracts-sdk/Cargo.tomlsetsdefault = []and contains no[[example]]/required-featuresforgen_grpc_proto. Result: the documented command (cargo run --example gen_grpc_proto -p cf-api-contracts-sdk) won’t compile unlessgrpc-clientis enabled.Update Line 8’s run command to include
--features grpc-client(or declarerequired-features = ["grpc-client"]for thegen_grpc_protoexample, or enablegrpc-clientby default).🤖 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 `@examples/toolkit/api-contracts/api-contracts-sdk/examples/gen_grpc_proto.rs` at line 8, The example `gen_grpc_proto.rs` imports `cf_api_contracts_sdk::grpc::payment_api_grpc_binding` which is only compiled under the `grpc-client` feature; update the example to be feature-gated by either (A) change the run instruction on the doc comment (the `cargo run --example gen_grpc_proto -p cf-api-contracts-sdk`) to include `--features grpc-client`, or (B) declare `required-features = ["grpc-client"]` for the `gen_grpc_proto` example in the examples/toolkit/api-contracts/api-contracts-sdk/Cargo.toml, or (C) enable the `grpc-client` feature by default—pick one approach and make the corresponding change so the import of `cf_api_contracts_sdk::grpc::payment_api_grpc_binding` is only used when `grpc-client` is enabled.libs/toolkit-contract/src/openapi/generator.rs-117-131 (1)
117-131:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDerive OpenAPI
requiredfrom the field metadata instead of hardcoding it.The generator currently marks every query/header parameter optional, and it marks the whole
requestBodyoptional unless all body fields are required. That understates the contract for common cases like a required query/header field or a body with one required and one optional property. The requiredness here should followfield_is_optional(...)for each binding and treat the body as required whenever at least one bound body field is mandatory.Also applies to: 238-246
🤖 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 `@libs/toolkit-contract/src/openapi/generator.rs` around lines 117 - 131, The code currently hardcodes required = false for HttpFieldBinding::Query and ::Header; change both places to compute required using the existing field_is_optional(method, field) helper (i.e., required = !field_is_optional(method, field)) when calling parameter_object (symbols: HttpFieldBinding::Query, HttpFieldBinding::Header, parameter_object, field_is_optional, field_schema). Also update the requestBody generation (the block around the requestBody creation referenced at lines ~238-246) to mark the whole requestBody as required whenever at least one bound body field is mandatory by scanning body bindings and setting request_body_required = body_fields.iter().any(|f| !field_is_optional(method, f)); pass that boolean into the requestBody construction instead of always making it optional.libs/toolkit-contract/src/runtime/canonical.rs-92-93 (1)
92-93:⚠️ Potential issue | 🟠 Major | ⚡ Quick winAvoid byte-slicing the response body here.
&body[..200]will panic whenever the 200th byte lands in the middle of a UTF-8 code point. Since this path processes arbitrary remote error text, a long non-ASCII body can crash canonicalization instead of returning a fallback error. Truncate on character boundaries instead.🤖 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 `@libs/toolkit-contract/src/runtime/canonical.rs` around lines 92 - 93, The code in http_status_to_canonical currently slices the UTF-8 string with &body[..200], which can panic on multibyte characters; change the truncation to operate on character boundaries instead (e.g., build a truncated string using chars().take(200) or find the nth char byte index via char_indices) and use that safe substring as preview so arbitrary non-ASCII response bodies cannot cause a panic in http_status_to_canonical.libs/toolkit-transport-grpc/src/lib.rs-25-31 (1)
25-31:⚠️ Potential issue | 🟠 Major | ⚡ Quick winUse an encoded-size budget here, not an 8 KiB raw JSON budget.
The truncation guard currently allows up to 8192 raw bytes, but the comment just above correctly notes that
-binmetadata is base64-expanded on the wire. That means a trailer can pass this check and still exceed the effective metadata budget, so the "truncate if too large" path won't trigger when it should. Either reduce the raw cap to the pre-base64 size you actually want, or enforce against the encoded size instead.Also applies to: 155-157
🤖 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 `@libs/toolkit-transport-grpc/src/lib.rs` around lines 25 - 31, The constant MAX_PROBLEM_TRAILER_BYTES currently represents a raw JSON byte cap but the trailer is base64-encoded on the wire, so update the implementation to enforce the post-base64 (encoded) size budget instead of the raw size: either change the truncation/validation logic wherever MAX_PROBLEM_TRAILER_BYTES is used to compute the base64-encoded length (e.g., base64 length <= MAX_PROBLEM_TRAILER_BYTES) and keep the constant meaning as “encoded bytes”, or reduce the raw-byte constant to the pre-encoding budget (8192 * 3/4 ≈ 6144) so raw JSON + base64 expansion fits the 8 KiB wire limit; apply the same change to the other identical trailer-cap constant referenced later in the file.libs/toolkit-contract/src/policy.rs-119-123 (1)
119-123:⚠️ Potential issue | 🟠 Major | ⚡ Quick winContinue unwinding
on_responsehooks after the first failure.Lines 119-123 return immediately on the first
on_responseerror, so earlier policies never see completion even though theiron_requestsucceeded. That breaks the stack symmetry you already preserve on the request-error path and can skip per-call cleanup/bookkeeping in outer policies.Suggested fix
- for policy in self.policies.iter().rev() { - if let Err(e) = policy.on_response(ctx, success).await { - return Err(map_policy_err(e)); - } - } - - result + let mut response_err = None; + for policy in self.policies.iter().rev() { + if let Err(e) = policy.on_response(ctx, success).await { + response_err.get_or_insert(e); + } + } + + if let Some(e) = response_err { + Err(map_policy_err(e)) + } else { + result + }🤖 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 `@libs/toolkit-contract/src/policy.rs` around lines 119 - 123, The on_response loop currently returns on the first error, preventing later policies from running their completion hooks; change the logic in the reverse iteration over self.policies in on_response to call every policy.on_response(ctx, success).await, record the first Err(e) you encounter (but do not return immediately), continue invoking the remaining policies, and after the loop return Err(map_policy_err(first_error)) if any error was recorded, otherwise return Ok(...). Keep references to the existing symbols self.policies, on_response, and map_policy_err so the change is localized to that loop.libs/toolkit-contract/src/ir/validation.rs-195-223 (1)
195-223:⚠️ Potential issue | 🟠 Major | ⚡ Quick winReject
Pathbindings whose{param}is missing from the template.Lines 213-221 only validate template → binding coverage. They never validate binding → template coverage, so a binding like
Path { param: "id", ... }on"/things"passes validation even though no generated client/server path can materialize it.Suggested fix
fn validate_path_params( method_binding: &HttpMethodBindingIr, method_loc: &str, errors: &mut Vec<ValidationError>, ) { let template_params = extract_path_params(&method_binding.path_template); + let template_param_set: HashSet<&str> = + template_params.iter().map(String::as_str).collect(); let path_binding_params: HashSet<&str> = method_binding .field_bindings .iter() .filter_map(|fb| { if let HttpFieldBinding::Path { param, .. } = fb { @@ for param in &template_params { if !path_binding_params.contains(param.as_str()) { errors.push(ValidationError { location: method_loc.to_owned(), message: format!( "path template parameter '{{{param}}}' has no corresponding Path field binding" ), }); } } + + for ¶m in &path_binding_params { + if !template_param_set.contains(param) { + errors.push(ValidationError { + location: method_loc.to_owned(), + message: format!( + "Path field binding references '{{{param}}}', but the path template does not declare that parameter" + ), + }); + } + } }🤖 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 `@libs/toolkit-contract/src/ir/validation.rs` around lines 195 - 223, validate_path_params currently only checks that each template parameter has a Path binding; add the inverse check to reject Path bindings whose param is not present in the template: after building template_params (from extract_path_params(&method_binding.path_template)), iterate method_binding.field_bindings, filter for HttpFieldBinding::Path variants (same pattern used earlier), and for each Path { param, .. } verify template_params contains param.as_str(); if not, push a ValidationError (using the same method_loc and a clear message like "Path field binding param '{param}' is not present in path template"). This uses the existing symbols validate_path_params, method_binding.field_bindings, HttpFieldBinding::Path, and extract_path_params.libs/toolkit-contract/src/ir/validation.rs-239-260 (1)
239-260:⚠️ Potential issue | 🟠 Major | ⚡ Quick winPreserve
FieldRolewhen validating HTTP-exposed fields.Line 239 collapses the method input to
HashSet<&str>, soSecurityContextfields are treated the same as normal wire fields. That letsQuery/Header/Pathbindings expose server-injected context across HTTP, even thoughFieldRoleexplicitly says those fields must not cross the transport boundary.Suggested fix
-use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; @@ - let input_field_names: HashSet<&str> = contract_method + let input_fields: HashMap<&str, crate::ir::contract::FieldRole> = contract_method .input .fields .iter() - .map(|f| f.name.as_str()) + .map(|f| (f.name.as_str(), f.role)) .collect(); @@ - if !input_field_names.contains(field.as_str()) { - errors.push(ValidationError { - location: method_loc.to_owned(), - message: format!( - "{kind} binding references field '{field}' not found in contract method input" - ), - }); + match input_fields.get(field.as_str()).copied() { + None => { + errors.push(ValidationError { + location: method_loc.to_owned(), + message: format!( + "{kind} binding references field '{field}' not found in contract method input" + ), + }); + } + Some(crate::ir::contract::FieldRole::SecurityContext) => { + errors.push(ValidationError { + location: method_loc.to_owned(), + message: format!( + "{kind} binding must not reference security-context field '{field}'" + ), + }); + } + Some(crate::ir::contract::FieldRole::Wire) => {} } } }🤖 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 `@libs/toolkit-contract/src/ir/validation.rs` around lines 239 - 260, The current validation collapses contract_method.input.fields into a HashSet<&str> (input_field_names) losing each field's FieldRole, which allows server-only fields (e.g., SecurityContext) to be bound to HttpFieldBinding::Path/Query/Header; update the logic to preserve each field's role (e.g., build a map from field.name -> field.role or lookup the field by name on contract_method.input.fields) and then: 1) if a binding references a non-existent field, push the existing ValidationError, and 2) if the field exists but its FieldRole disallows transport exposure (e.g., SecurityContext), push a ValidationError explaining that the specific HttpFieldBinding (Path/Query/Header) may not bind server-injected fields; adjust references to input_field_names, contract_method, method_binding, and HttpFieldBinding accordingly.libs/toolkit-contract/src/runtime/http.rs-179-196 (1)
179-196:⚠️ Potential issue | 🟠 Major | ⚡ Quick winStructured query flattening drops the declared binding name.
Once recursion enters an object, it stops using the bound
paramand emits each child key directly. For example,Query { field: "filter", param: "q" }plus{ "status": "paid" }becomesstatus=paid, not something rooted atq. That changes the on-the-wire contract and can collide with sibling query bindings.🤖 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 `@libs/toolkit-contract/src/runtime/http.rs` around lines 179 - 196, The flatten_query_value function currently drops the parent binding when recursing into serde_json::Value::Object by using the child key directly; update the Object branch to preserve the parent param by composing a nested key (for example use format!("{}[{}]", param, k) or another agreed separator) and pass that composed key into flatten_query_value so emitted pairs remain rooted at the original binding (i.e., change flatten_query_value(k, v, out) to flatten_query_value(&format!("{}[{}]", param, k), v, out) and keep the existing v.is_null() check).libs/toolkit-contract/src/http/dispatch.rs-67-69 (1)
67-69:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftKeep query-binding semantics identical to the runtime URL builder.
HttpFieldBinding::Querygoes throughfield_as_stringhere, so objects/arrays are rejected, butlibs/toolkit-contract/src/runtime/http.rsaccepts structured query values and even tests object flattening. The sameHttpMethodBindingIrcan therefore succeed in the generated runtime client and fail in dispatch/OpenAPI paths. Please centralize this logic or enforce one query-shape policy across both helpers.Also applies to: 105-121
🤖 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 `@libs/toolkit-contract/src/http/dispatch.rs` around lines 67 - 69, The dispatch code for HttpFieldBinding::Query currently calls field_as_string (rejecting arrays/objects) which diverges from the runtime URL builder's handling (which accepts structured values and flattens objects); fix this by centralizing query-shaping logic: replace the direct field_as_string call in the HttpFieldBinding::Query handling (and the similar block around the other occurrences) with the shared helper used by libs/toolkit-contract/src/runtime/http.rs (or extract a new common function named something like field_to_query_pairs / flatten_field_query) so both the dispatch/OpenAPI path and runtime URL builder use the same function to produce query pairs from a field, ensuring objects/arrays are flattened/serialized identically. Ensure you reference and reuse HttpMethodBindingIr’s existing query-flattening behavior and update tests if necessary.libs/toolkit-contract/src/runtime/http.rs-151-154 (1)
151-154:⚠️ Potential issue | 🟠 Major | ⚡ Quick winNormalize parsed
Problem.statusagainst the wire status.
TransportError::is_transient()uses the embeddedProblem.status, so a mismatched envelope here can change retry behavior and diagnostics away from the actual HTTP status that triggered this path. The response status is authoritative on the wire; validate or overwrite the parsed payload before returning it.Suggested fix
pub fn map_http_error(status: u16, body: String) -> TransportError { - if let Ok(problem) = serde_json::from_str::<Problem>(&body) { + if let Ok(mut problem) = serde_json::from_str::<Problem>(&body) { + problem.status = status; return TransportError::Problem(problem); } TransportError::HttpStatus { status, body: truncate(body, 256),🤖 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 `@libs/toolkit-contract/src/runtime/http.rs` around lines 151 - 154, The parsed Problem from serde_json::from_str in map_http_error may contain a different Problem.status than the actual HTTP status; update map_http_error so that after successfully parsing a Problem you normalize or overwrite problem.status with the authoritative wire status (convert the u16 status to the Problem.status type or set the numeric field) before returning TransportError::Problem(problem) so TransportError::is_transient() and diagnostics reflect the real HTTP response code.libs/toolkit-contract/src/runtime/http.rs-204-208 (1)
204-208:⚠️ Potential issue | 🟠 Major | ⚡ Quick winAvoid panicking while truncating UTF-8 response bodies.
String::truncaterequires a char boundary. If byte256lands in the middle of a multibyte character, error mapping panics and turns a normal HTTP failure into a client crash.Suggested fix
fn truncate(mut s: String, max: usize) -> String { if s.len() > max { - s.truncate(max); + let cut = s + .char_indices() + .map(|(idx, _)| idx) + .take_while(|idx| *idx <= max) + .last() + .unwrap_or(0); + s.truncate(cut); s.push('\u{2026}'); } s }🤖 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 `@libs/toolkit-contract/src/runtime/http.rs` around lines 204 - 208, The truncate function can panic because String::truncate requires a char boundary; update truncate(s: String, max: usize) to compute a safe boundary before truncating: when s.len() > max, find a mutable index m = max and decrement m while m > 0 and !s.is_char_boundary(m), then call s.truncate(m) and push the ellipsis; reference the truncate function and parameters s and max to locate the code and ensure you use the safe boundary (e.g., s.is_char_boundary) instead of calling s.truncate(max) directly.libs/toolkit-contract/src/runtime/client.rs-169-179 (1)
169-179:⚠️ Potential issue | 🟠 Major | ⚡ Quick winRetry transient HTTP statuses during SSE reconnects.
This branch returns immediately on any non-success response, so
503,429,408, etc. end the stream even whenreconnect.max_attempts > 0. That bypasses the transient classification already encoded inTransportError::is_transient()and makes overload/rolling-restart scenarios look permanent.Suggested fix
if !status.is_success() { let status_code = status.as_u16(); let bytes = response.bytes().await.map_err(TransportError::network)?; let body = String::from_utf8_lossy(&bytes).into_owned(); let err = map_http_error(status_code, body); - // Non-success responses are typically domain errors — - // bubble straight through without reconnect (server - // explicitly told us "no"). + if attempt < reconnect.max_attempts && err.is_transient() { + attempt += 1; + sleep_backoff(&reconnect, attempt).await; + continue; + } Err(err)?; return; }🤖 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 `@libs/toolkit-contract/src/runtime/client.rs` around lines 169 - 179, The code currently treats any non-success HTTP response as a terminal error; instead, after mapping the response to an error via map_http_error you should check TransportError::is_transient() (or equivalent transient classification) and the reconnect policy (reconnect.max_attempts) and, if the error is transient and reconnect.max_attempts > 0, propagate it in a way that triggers the SSE reconnect/retry logic rather than immediately bailing out; for non-transient/domain errors keep the existing immediate return behavior. Locate the logic around response.status(), map_http_error, and the Err(err)? return and branch based on err.is_transient() + reconnect.max_attempts to drive retries.libs/toolkit-contract-macros/src/codegen.rs-76-88 (1)
76-88:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftDescriptor
input_typecurrently tracks the last parameter, not the request shape.
input_typeis derived fromm.params.last(), but the parser already distinguishesParamRole::SecurityContextand the IR keeps every parameter. A method likefn charge(req: Charge, #[secctx] ctx: SecurityContext)will advertiseSecurityContextas its request type, and multi-wire methods advertise only their last field. Filter to wire params here and either reject multi-wire signatures earlier or encode the full input shape in the descriptor.🤖 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 `@libs/toolkit-contract-macros/src/codegen.rs` around lines 76 - 88, The descriptor currently sets input_type from m.params.last(), which incorrectly picks SecurityContext or only the last field; update the logic in the method_descriptors construction to filter m.params by wire parameters (exclude ParamRole::SecurityContext) before computing input_type_str via type_name_str, and then: if the filtered list has zero params use Default/Unit, if it has one use that param's type, and if it has multiple either return an error earlier (reject multi-wire signatures) or encode the full input shape (e.g., synthesize a tuple/struct type name) — adjust callers like method_kind_tokens/idempotency_tokens usages accordingly so the descriptor reflects the true request shape.libs/toolkit-contract-macros/src/support.rs-14-17 (1)
14-17:⚠️ Potential issue | 🟠 MajorFix
FoundCrate::Itselfpath resolution incontract_support_path()
cf-gears-toolkit-contract’s library name istoolkit_contract, butcontract_support_path()mapsproc_macro_crate::FoundCrate::Itselfto::toolkit_contractinlibs/toolkit-contract-macros/src/support.rs, and there is noextern crate self as toolkit_contractanywhere in the repo. This can break macro expansion when used from within the crate (unit/in-crate usage), even though current macro usage appears only inlibs/toolkit-contract/tests/*(integration tests). Emitcratefor theItselfcase instead of::toolkit_contract.🤖 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 `@libs/toolkit-contract-macros/src/support.rs` around lines 14 - 17, In contract_support_path(), fix the FoundCrate::Itself mapping so generated code refers to the local crate instead of a hardcoded absolute path: replace the branch that returns quote!(::toolkit_contract) for proc_macro_crate::FoundCrate::Itself with a return that emits the local crate token (e.g., quote!(crate)) so macros expand correctly when used inside the crate; update the match arm in contract_support_path() that currently handles FoundCrate::Itself to emit the crate token.libs/toolkit-contract-macros/src/codegen.rs-35-38 (1)
35-38:⚠️ Potential issue | 🟠 MajorStop hard-coding
#[::async_trait::async_trait]in generated contract traits.
libs/toolkit-contract-macros/src/codegen.rs’sgenerate_traitinjects#[::async_trait::async_trait]directly, instead of using the crate path resolved bycontract_support_path()(or an equivalent guaranteed re-export). That makesasync-traita hidden dependency of every macro consumer.- The descriptor metadata in
libs/toolkit-contract-macros/src/codegen.rs’sgenerate_descriptorusesm.params.last()forMethodDescriptor.input_type. Sinceparamsincludes security-context params (and can include multiple wire params), the recordedinput_typecan diverge from the actual wire/body param selection logic used by GRPC/REST macros.Implement
async-traitvia the resolved support path (or ensure bothtoolkit_contractandtoolkit::contract_supportre-export it at a stable location) and computeinput_typefrom the same wire/body parameter rule used for GRPC/REST instead ofparams.last().🤖 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 `@libs/toolkit-contract-macros/src/codegen.rs` around lines 35 - 38, In generate_trait in libs/toolkit-contract-macros/src/codegen.rs, stop injecting the hard-coded attribute #[::async_trait::async_trait] and instead apply the async-trait attribute via the resolved support path (use the crate path returned by contract_support_path() or the stable re-export in toolkit_contract/toolkit::contract_support) so consumers don't get a hidden dependency; and in generate_descriptor compute MethodDescriptor.input_type using the same wire/body parameter selection logic used by the GRPC/REST macros (i.e., replicate or call the function that filters out security-context params and picks the actual wire/body param) instead of using params.last(), ensuring the recorded input_type matches the real wire/body param selection.libs/toolkit-contract-macros/src/rest_contract_parse.rs-165-175 (1)
165-175:⚠️ Potential issue | 🟠 Major | ⚡ Quick winBase-trait resolution should ignore marker supertraits in both parsers.
libs/toolkit-contract-macros/src/rest_contract_parse.rsandlibs/toolkit-contract-macros/src/grpc_contract_parse.rsboth pick the first trait bound as the base contract. That breaks valid declarations liketrait FooRest: Send + Sync + FooApi/trait FooGrpc: Send + Sync + FooApiby resolving the base toSend. The shared fix is to skip marker supertraits and select the first non-marker contract bound in both files.🤖 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 `@libs/toolkit-contract-macros/src/rest_contract_parse.rs` around lines 165 - 175, The base-trait resolution currently returns the first trait bound from ItemTrait::supertraits (see extract_base_trait) which picks marker traits like Send/Sync; change extract_base_trait to iterate supertraits and skip known marker/auto traits (e.g., Send, Sync, Sized, Unpin, 'static, etc.) by inspecting each syn::TypeParamBound::Trait(t) -> t.path and returning the first non-marker path, and apply the same update to the equivalent resolver in the gRPC parser so both parsers select the first non-marker contract bound as the base contract.libs/toolkit-contract-macros/src/rest_contract.rs-263-280 (1)
263-280:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDo not default generated clients to plaintext HTTP.
new()builds its default client withAllowInsecureHttp, and the generated request path adds bearer tokens when aSecurityContextis present. That makes the out-of-the-box path capable of sending credentials over cleartexthttp://URLs.Please make insecure transport an explicit opt-in on
with_http_clientor a dedicated constructor, not the default constructor path.🤖 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 `@libs/toolkit-contract-macros/src/rest_contract.rs` around lines 263 - 280, The default HTTP client currently allows plaintext via TransportSecurity::AllowInsecureHttp inside build_default_http_client(), which enables sending bearer tokens over cleartext; change build_default_http_client() to require TLS (use TransportSecurity::RequireTls or equivalent) so new()/try_new() default to secure transport, remove AllowInsecureHttp from the default path, and add an explicit opt-in API (e.g., a new constructor or a with_insecure_http()/with_http_client parameter) that documents and sets TransportSecurity::AllowInsecureHttp for callers that intentionally need plaintext; update relevant docs/comments for build_default_http_client(), new(), try_new(), and with_http_client to reflect the new default and opt-in behavior.libs/toolkit-contract-macros/src/rest_contract.rs-329-338 (1)
329-338:⚠️ Potential issue | 🟠 Major | ⚡ Quick winStreaming REST methods drop the request body on POST/PUT.
The unary path threads
body_capturethroughwith_json_body(), but the streaming path never receives or applies it. A#[streaming] #[post(...)]or#[put(...)]method will therefore generate a request without the payload even though the binding model marks one parameter asBody.Either thread the captured body into
__factoryand applywith_json_body()there, or reject body-carrying verbs for streaming methods during parse so this fails at macro expansion instead of changing request semantics silently.Also applies to: 414-504
🤖 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 `@libs/toolkit-contract-macros/src/rest_contract.rs` around lines 329 - 338, The streaming branch (when method.streaming is true) never applies the captured body (body_capture) into the generated request, so POST/PUT streaming methods drop the payload; update generate_streaming_method_body to accept the body_capture (or thread it into __factory) and call with_json_body(...) on the request factory before sending, or alternatively add a parse-time validation in the method parsing code to reject streaming methods that declare a Body parameter (i.e., detect Body in the binding model and error) so we don't silently drop the body; reference generate_streaming_method_body, body_capture, with_json_body, and __factory when making the change.libs/toolkit-contract-macros/src/rest_contract_parse.rs-185-200 (1)
185-200:⚠️ Potential issue | 🟠 Major | ⚡ Quick winReject multiple HTTP verb attributes on the same method.
A method annotated with both
#[get(...)]and#[post(...)]is accepted today because each branch overwriteshttp. The last attribute wins instead of producing a diagnostic, which makes the generated binding depend on attribute order.Suggested fix
for attr in &method.attrs { let path = attr.path(); if path.is_ident("get") { - http = Some((HttpVerb::Get, parse_path_lit(attr)?, attr.span())); + if http.is_some() { + return Err(syn::Error::new(attr.span(), "rest_contract methods may declare exactly one HTTP verb attribute")); + } + http = Some((HttpVerb::Get, parse_path_lit(attr)?, attr.span())); } else if path.is_ident("post") { - http = Some((HttpVerb::Post, parse_path_lit(attr)?, attr.span())); + if http.is_some() { + return Err(syn::Error::new(attr.span(), "rest_contract methods may declare exactly one HTTP verb attribute")); + } + http = Some((HttpVerb::Post, parse_path_lit(attr)?, attr.span())); } else if path.is_ident("put") { - http = Some((HttpVerb::Put, parse_path_lit(attr)?, attr.span())); + if http.is_some() { + return Err(syn::Error::new(attr.span(), "rest_contract methods may declare exactly one HTTP verb attribute")); + } + http = Some((HttpVerb::Put, parse_path_lit(attr)?, attr.span())); } else if path.is_ident("delete") { - http = Some((HttpVerb::Delete, parse_path_lit(attr)?, attr.span())); + if http.is_some() { + return Err(syn::Error::new(attr.span(), "rest_contract methods may declare exactly one HTTP verb attribute")); + } + http = Some((HttpVerb::Delete, parse_path_lit(attr)?, attr.span())); } else if path.is_ident("retryable") {🤖 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 `@libs/toolkit-contract-macros/src/rest_contract_parse.rs` around lines 185 - 200, The loop over method.attrs currently lets multiple HTTP verb attributes overwrite the http variable; update the code in the method.attrs handling (the block that sets http via HttpVerb::Get/Post/Put/Delete using parse_path_lit and attr.span()) to detect if http.is_some() before assigning and produce a diagnostic instead of overwriting: if http.is_some() return Err(syn::Error::new_spanned(attr, "multiple HTTP verb attributes on the same method are not allowed")) (use attr.span() or attr for span), otherwise set http as before; do this check for each branch that assigns http so the first verb wins or an error is emitted.libs/toolkit-contract-macros-tests/tests/ui/fail/grpc_fake_secctx.stderr-1-17 (1)
1-17:⚠️ Potential issue | 🟠 Major
grpc_contractexpands#[cfg(feature="grpc-client")]into the caller and triggersunexpected_cfgs
libs/toolkit-contract-macros-tests/Cargo.tomlbuilds the UI test crate without agrpc-clientfeature (onlytoolkit-contractis enabled withfeatures = ["rest-client"]), yet thegrpc_contractattribute macro still emits#[cfg(feature = "grpc-client")], producing anunexpected cfg condition value: grpc-clientwarning intests/ui/fail/grpc_fake_secctx.rs(and the same warning appears ingrpc_unsupported_param_type.stderr) before the intended type-check failure. Crates with-D warningscan fail on this warning instead of the semantic error.Adjust the macro expansion to avoid emitting an
unexpected_cfgs-triggeringcfg(feature="grpc-client")in callers without that feature (e.g., gate via type-level errors rather than a featurecfg, or suppressunexpected_cfgslocally around the generated cfg usage) so the UI goldens only bless the intended failure.🤖 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 `@libs/toolkit-contract-macros-tests/tests/ui/fail/grpc_fake_secctx.stderr` around lines 1 - 17, The macro grpc_contract is emitting #[cfg(feature = "grpc-client")] into the caller which triggers unexpected_cfgs when the caller crate doesn't have that feature; change the macro expansion so it does not emit that caller-level cfg: either (preferred) replace the feature-gating with a type-level or compile_error! based gate inside generated items (e.g., generate a phantom trait/type or a compile_error when the runtime symbols are used) instead of #[cfg(feature="grpc-client")], or (quick/acceptable) wrap the emitted cfg usage with a local suppression by emitting #[allow(unexpected_cfgs)] on the generated items that contain #[cfg(feature = "grpc-client")]; update the code paths in the grpc_contract macro where it currently inserts "cfg(feature = \"grpc-client\")" to use one of these approaches so tests like the expansion in grpc_fake_secctx.rs no longer produce the unexpected_cfgs warning.libs/toolkit-contract/tests/contract_error_derive.rs-9-10 (1)
9-10:⚠️ Potential issue | 🟠 Major | ⚡ Quick winRun rustfmt on this import block.
Line 9 and Line 10 are out of rustfmt order, and the
Fmt / 0_cargo fmtcheck is already failing on this file.Suggested fix
-use toolkit_contract::{ContractError, Problem}; use serde::{Deserialize, Serialize}; +use toolkit_contract::{ContractError, Problem};🤖 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 `@libs/toolkit-contract/tests/contract_error_derive.rs` around lines 9 - 10, Reorder and format the import block (or run rustfmt) so the two use statements are in rustfmt's canonical order: place use serde::{Deserialize, Serialize}; before use toolkit_contract::{ContractError, Problem}; (or simply run cargo fmt / rustfmt on the file) to fix the Fmt / 0_cargo fmt failure referencing the import lines with use serde::{Deserialize, Serialize} and use toolkit_contract::{ContractError, Problem}.Source: Pipeline failures
| #[tokio::test] | ||
| async fn test_resolve_rest_service_not_found() { | ||
| let dir = Arc::new(ModuleManager::new()); | ||
| let api = LocalDirectoryClient::new(dir); | ||
|
|
||
| let result = api.resolve_rest_service("nonexistent_module").await; | ||
| assert!(result.is_err()); | ||
| } |
There was a problem hiding this comment.
Tests reference undefined types ModuleManager and ModuleInstance.
The tests use ModuleManager::new() and ModuleInstance::new() but this file imports GearManager and GearInstance from crate::runtime. These types don't exist and the tests won't compile.
Proposed fix for all affected tests
#[tokio::test]
async fn test_resolve_rest_service_not_found() {
- let dir = Arc::new(ModuleManager::new());
+ let dir = Arc::new(GearManager::new());
let api = LocalDirectoryClient::new(dir);And similarly for lines 242, 247, 263, 293, 298, 320, 324 — replace ModuleManager with GearManager and ModuleInstance with GearInstance.
🤖 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 `@libs/toolkit/src/directory.rs` around lines 231 - 238, Tests are failing to
compile because they reference non-existent types ModuleManager and
ModuleInstance; replace those with the actual types exported from crate::runtime
(GearManager and GearInstance) wherever used (e.g., in
test_resolve_rest_service_not_found and the other tests that call
LocalDirectoryClient, resolve_rest_service, resolve_rest_instance, etc.), update
the constructors to Arc::new(GearManager::new()) and GearInstance::new() (or the
appropriate constructor signatures), and adjust any imports/uses to reference
GearManager and GearInstance instead of ModuleManager/ModuleInstance across the
affected tests.
| #[tokio::test] | ||
| async fn test_resolve_rest_service_not_found() { | ||
| let dir = Arc::new(ModuleManager::new()); | ||
| let api = LocalDirectoryClient::new(dir); | ||
|
|
||
| let result = api.resolve_rest_service("nonexistent_module").await; | ||
| assert!(result.is_err()); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_resolve_rest_service_found() { | ||
| let dir = Arc::new(ModuleManager::new()); | ||
| let api = LocalDirectoryClient::new(dir.clone()); | ||
|
|
||
| let instance_id = Uuid::new_v4(); | ||
| let inst = Arc::new( | ||
| ModuleInstance::new("billing", instance_id) | ||
| .with_rest_endpoint(Endpoint::http("billing-service", 8080)), | ||
| ); | ||
| dir.register_instance(inst); | ||
|
|
||
| // Mark as healthy so round-robin prefers it | ||
| dir.update_heartbeat("billing", instance_id, std::time::Instant::now()); | ||
|
|
||
| let result = api.resolve_rest_service("billing").await; | ||
| assert!(result.is_ok()); | ||
| let ep = result.unwrap(); | ||
| assert_eq!(ep.uri, "http://billing-service:8080"); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_register_instance_with_rest_endpoint() { | ||
| let dir = Arc::new(ModuleManager::new()); | ||
| let api = LocalDirectoryClient::new(dir.clone()); | ||
|
|
||
| let instance_id = Uuid::new_v4(); | ||
| let register_info = RegisterInstanceInfo { | ||
| module: "billing".to_owned(), | ||
| instance_id: instance_id.to_string(), | ||
| grpc_services: vec![GrpcServiceInfo { | ||
| service_name: "billing.BillingService".to_owned(), | ||
| endpoint: ServiceEndpoint::http("127.0.0.1", 9001), | ||
| }], | ||
| version: Some("1.0.0".to_owned()), | ||
| rest_endpoint: Some(ServiceEndpoint::http("127.0.0.1", 8080)), | ||
| }; | ||
|
|
||
| api.register_instance(register_info).await.unwrap(); | ||
|
|
||
| // Verify the instance was registered with REST endpoint | ||
| let instances = dir.instances_of("billing"); | ||
| assert_eq!(instances.len(), 1); | ||
| assert_eq!(instances[0].instance_id, instance_id); | ||
| assert!(instances[0].rest_endpoint.is_some()); | ||
| assert_eq!( | ||
| instances[0].rest_endpoint.as_ref().unwrap().uri, | ||
| "http://127.0.0.1:8080" | ||
| ); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_list_instances_includes_rest_endpoint() { | ||
| let dir = Arc::new(ModuleManager::new()); | ||
| let api = LocalDirectoryClient::new(dir.clone()); | ||
|
|
||
| let instance_id = Uuid::new_v4(); | ||
| let inst = Arc::new( | ||
| ModuleInstance::new("billing", instance_id) | ||
| .with_grpc_service("billing.Service", Endpoint::http("127.0.0.1", 9001)) | ||
| .with_rest_endpoint(Endpoint::http("127.0.0.1", 8080)), | ||
| ); | ||
| dir.register_instance(inst); | ||
|
|
||
| let instances = api.list_instances("billing").await.unwrap(); | ||
| assert_eq!(instances.len(), 1); | ||
| assert!(instances[0].rest_endpoint.is_some()); | ||
| assert_eq!(instances[0].grpc_services.len(), 1); | ||
| assert_eq!( | ||
| instances[0].grpc_services[0].service_name, | ||
| "billing.Service" | ||
| ); | ||
| assert_eq!( | ||
| instances[0].rest_endpoint.as_ref().unwrap().uri, | ||
| "http://127.0.0.1:8080" | ||
| ); | ||
| } | ||
|
|
||
| #[tokio::test] | ||
| async fn test_list_instances_includes_rest_only_instance() { | ||
| let dir = Arc::new(ModuleManager::new()); | ||
| let api = LocalDirectoryClient::new(dir.clone()); | ||
|
|
||
| let instance_id = Uuid::new_v4(); | ||
| let inst = Arc::new( | ||
| ModuleInstance::new("rest-only", instance_id) | ||
| .with_rest_endpoint(Endpoint::http("127.0.0.1", 8088)), | ||
| ); | ||
| dir.register_instance(inst); | ||
|
|
||
| let instances = api.list_instances("rest-only").await.unwrap(); | ||
| assert_eq!(instances.len(), 1); | ||
| assert!(instances[0].grpc_services.is_empty()); | ||
| assert_eq!( | ||
| instances[0].rest_endpoint.as_ref().unwrap().uri, | ||
| "http://127.0.0.1:8088" | ||
| ); | ||
| } |
There was a problem hiding this comment.
Tests in libs/toolkit/src/directory.rs and libs/toolkit/src/runtime/gear_manager.rs reference undefined types.
Both files' new tests use ModuleManager, ModuleInstance, and pick_rest_module which don't exist in the codebase. The correct types defined in gear_manager.rs are GearManager, GearInstance, and pick_rest_gear. Additionally, RegisterInstanceInfo uses the field gear, not module.
This appears to be a naming inconsistency—possibly from an earlier iteration or copy-paste from a different module naming convention. All affected tests will fail to compile.
🤖 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 `@libs/toolkit/src/directory.rs` around lines 231 - 337, Replace the incorrect
module-oriented types/fields in the tests with the gear-oriented names used in
the codebase: change ModuleManager to GearManager, ModuleInstance to
GearInstance, pick_rest_module to pick_rest_gear, and use the
RegisterInstanceInfo field gear (not module); update any related
constructors/usages (e.g., ModuleInstance::new(...) -> GearInstance::new(...),
ModuleManager::new() -> GearManager::new(), and any uses of module/instance_id
fields) so the test references match GearManager, GearInstance, pick_rest_gear,
and RegisterInstanceInfo.gear.
| let register_info = RegisterInstanceInfo { | ||
| module: "billing".to_owned(), | ||
| instance_id: instance_id.to_string(), |
There was a problem hiding this comment.
Test uses wrong field name .module instead of .gear.
RegisterInstanceInfo has a gear field (as seen in line 94's usage: info.gear.clone()), not a module field. This won't compile.
Proposed fix
let register_info = RegisterInstanceInfo {
- module: "billing".to_owned(),
+ gear: "billing".to_owned(),
instance_id: instance_id.to_string(),📝 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.
| let register_info = RegisterInstanceInfo { | |
| module: "billing".to_owned(), | |
| instance_id: instance_id.to_string(), | |
| let register_info = RegisterInstanceInfo { | |
| gear: "billing".to_owned(), | |
| instance_id: instance_id.to_string(), |
🤖 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 `@libs/toolkit/src/directory.rs` around lines 267 - 269, The code is
constructing a RegisterInstanceInfo with a non-existent .module field; change
the struct literal in the creation of register_info to use the correct field
name .gear (e.g., gear: "billing".to_owned()) and keep instance_id:
instance_id.to_string() so it matches the RegisterInstanceInfo definition used
elsewhere (see RegisterInstanceInfo and register_info).
3099024 to
b49c5a4
Compare
Introduce the first PoC of the ModKit contract system. - add `modkit-contract`, `modkit-contract-macros`, and `modkit-contract-protogen` - add contract descriptors, IR validation, OpenAPI generation, and runtime support - add REST and gRPC contract/codegen support with proto lockfile handling - extend canonical error wire-compatibility support - add macro UI tests and contract integration tests - add `examples/modkit/api-contracts` sample module and SDK Signed-off-by: Mike Yastrebtsov <3686170+MikeFalcon77@users.noreply.github.com>
…ransport Add consumer-side lazy discovery and provider-side auto-registration so a gear can consume a contract whose provider is not yet ready, tolerate the provider moving or vanishing at runtime, and have its own REST endpoint advertised in the service directory automatically. Consumer side: - DirectoryResolvingClient<C>: self-healing REST wrapper that resolves the provider endpoint from the directory per call, caches the built client by endpoint, and rebuilds on change — recovers automatically after eviction. - EndpointResolver trait + ResolveError (Ok(None)=not-ready vs Err=backend outage); tracing on resolve failure / rebuild. - #[toolkit::rest_contract] now also emits <Trait>ResolvingClient (gated behind the new `directory-rest-client` feature), delegating unary + SSE methods. - TransportError::Unresolved (transient -> CanonicalError::ServiceUnavailable, carrying the gear name in the detail). - ClientHub::try_get for compile-time-impl short-circuit. - toolkit: DirectoryEndpointResolver adapter + ConsumerRegistration inventory + run_proxy_wiring_phase (after init, before post_init), gated behind `contract-directory-rest-client`. Provider side: - ApiGatewayCapability::bound_endpoint() (mirrors GrpcHubCapability); api-gateway publishes its bound REST base URL (loopback-substituted for 0.0.0.0/::). - run_directory_register_phase (after start) advertises every REST provider gear in the directory via the ClientHub DirectoryClient (in-process or OoP), merging existing gRPC services so it augments rather than clobbers the entry; deregister on shutdown. Gateway host excluded from provider registration. Also: fix synth_to_canonical panic for PermissionDenied (missing `reason` context field) and add ADR-0003/0004 to the binding DESIGN traceability. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-readiness e2e
Phase 3: replace hand-wired ConsumerRegistration with a macro, and prove the
full eventual-readiness path through a real HostRuntime.
- `#[toolkit::consumes(contract = X, from = "gear")]` (toolkit-contract-macros,
re-exported toolkit-contract -> toolkit): emits a non-capturing wire fn +
`inventory::submit!{ toolkit::discovery::ConsumerRegistration }` (gated behind
`directory-rest-client`) replayed by the runtime proxy-wiring phase. The wire
closure short-circuits if a compile-time local impl is present, else registers
the directory-resolving REST client. Resolver type re-exported as
`toolkit::discovery::EndpointResolver` so a consumer needs no direct
toolkit-contract dep; tuning via `Default::default()`.
- Deliberately does NOT inject a topo-sort dep (cannot mutate gear's &'static
deps from a separate attribute; would break for remote providers and
contradict non-blocking startup). Documented in ADR-0004; co-located hard deps
are declared in `#[toolkit::gear(deps=[...])]`.
- Share `parent_module`/`append_segment` via `crate::support` (used by provides
and consumes).
- Runtime e2e (`tests/runtime_eventual_readiness.rs`): provider gear serves REST
with NO local impl + minimal mock gateway host (binds TCP, publishes
bound_endpoint) + LocalDirectoryClient in the hub; driving HostRuntime exercises
proxy-wiring + directory-register so the consumer resolves the provider over
HTTP. Closes the Phase-2 runtime-phase test gap.
- Macro UI: valid_consumes (pass, cfg-gated) + consumes_missing_from /
consumes_unknown_arg (compile-fail).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4: expose process-level readiness so an orchestrator (k8s) can gate the pod in/out of the load balancer as consumed dependencies resolve. - `ReadinessState` (libs/toolkit/src/readiness.rs): a new process-level signal (Starting/Ready/Degraded/Draining), orthogonal to per-gear lifecycle::Status. Readiness = all consumed deps (from #[toolkit::consumes]) resolved in the directory; a no-consumer process is Ready immediately. Sticky + startup-gating (a provider vanishing later is liveness/call-time churn, not readiness). `report()` → http_status() (200 ready/degraded, 503 starting/draining) + JSON. Degraded variant reserved but unset in v1 (custom checks deferred). - HostRuntime publishes `Arc<ReadinessState>` in the ClientHub (concrete key). The proxy-wiring phase registers each consumed dep as a readiness gate and spawns a background probe loop (exp backoff 100ms→30s) that marks deps resolved via the directory resolver. A draining watcher flips the state on root cancel before the stop phase. - api-gateway mounts `/readyz` (raw axum, public/no-auth, mirrors /healthz) gated to the published ReadinessState via Extension; `/healthz` stays liveness-only. Probe-only: no active request-blocking middleware (k8s gates on 503). - Tests: 7 ReadinessState unit tests; the runtime e2e is extended to assert the process goes Starting → Ready (probe loop resolves the provider) → Draining (watcher on shutdown) through a real HostRuntime. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…dency resolution Surface directory-backend errors as warnings during readiness probes instead of silently treating them as unresolved dependencies. Adds explicit logging to distinguish transient startup churn (Ok(None)) from genuine directory failures. Also add: Insight architectural assessment (docs/insight-gears-assessment.md) — evaluation of Insight system's Gears adoption (current: ~40%, full blueprint for Phase 1–3 migration). Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…ct clients The generated `<Contract>RestClient::build_default_http_client()` now calls `toolkit-http`'s `.with_otel()`, so a directory-resolved contract hop emits an outbound span and propagates W3C trace context — joining the distributed trace instead of breaking it. Matches the existing convention (e.g. users-info gear builds its HttpClient with `.with_otel()`). Inert unless the build enables `toolkit-http/otel`; callers wanting a different stance use `with_http_client`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…eneration Implement initial server-side code generation for #[toolkit::rest_contract]: - Add generate_server_registration() function to emit register_<trait>_routes() - Function takes Router, OpenApiRegistry, and service Arc - Handler generation skeleton (unary and streaming handlers) - Feature-gate behind "rest-server" feature on toolkit-contract & SDK crates - Update DESIGN.md with D7 decision on server-side codegen - Extract response types from method result_types for response registration Remaining work (follow-up PR): - Proper handler implementation with parameter extraction - Error response mapping via utoipa Problem envelope - Integration testing with api-contracts example - Streaming response handling via SSE Relates to ADR-0003: Server-side route registration via OperationBuilder Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eneration Implement initial server-side code generation for #[toolkit::rest_contract]: - Add generate_server_registration() function to emit register_<trait>_routes() - Function takes Router, OpenApiRegistry, and service Arc - Handler generation skeleton (unary and streaming handlers) - Feature-gate behind "rest-server" feature on toolkit-contract & SDK crates - Update DESIGN.md with D7 decision on server-side codegen - Extract response types from method result_types for response registration Remaining work (follow-up PR): - Proper handler implementation with parameter extraction - Error response mapping via utoipa Problem envelope - Integration testing with api-contracts example - Streaming response handling via SSE Relates to ADR-0003: Server-side route registration via OperationBuilder Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
15a6db6 to
8df2ad9
Compare
Introduce the first PoC of the ModKit contract system.
modkit-contract,modkit-contract-macros, andmodkit-contract-protogenexamples/modkit/api-contractssample module and SDKSummary by CodeRabbit
New Features
Documentation
Improvements