From d81fa0b8deef44edfeb946dd5e171bd6ac0badd1 Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 30 Jul 2026 07:59:55 +0000
Subject: [PATCH 01/19] workspace: Apply clippy auto-fixes across crates
Mechanical clippy suggestions (redundant closures, format! simplifications,
collapsed if-lets, sort_by_key, char comparisons, matches! macro, etc.).
No behavior change; lib tests (242) + TS (17) still pass.
---
CHANGELOG.md | 18 ++
README.md | 29 +--
crates/specforge-core/src/changelog.rs | 6 +-
crates/specforge-core/src/deprecation.rs | 4 +-
crates/specforge-core/src/diff.rs | 2 +-
crates/specforge-core/src/mock.rs | 2 +-
crates/specforge-core/src/profiler.rs | 4 +-
crates/specforge-core/src/resolve.rs | 6 +-
crates/specforge-core/src/security.rs | 2 +-
crates/specforge-core/src/validate.rs | 5 +-
crates/specforge-core/src/verify.rs | 2 +-
crates/specforge-go/src/lib.rs | 30 ++-
crates/specforge-go/tests/gen_test.rs | 60 ++++++
crates/specforge-rust/src/lib.rs | 175 ++++++++++++++----
crates/specforge-rust/tests/gen_test.rs | 63 +++++++
crates/specforge-ts/src/operations.rs | 4 +-
crates/specforge-ts/src/runtime.rs | 33 +++-
crates/specforge-ts/tests/gen_test.rs | 61 ++++++
docs-site/docs/intro.md | 29 +--
docs-site/docs/reference/changelog.md | 16 ++
examples/petstore-go/sdk/client.go | 46 ++++-
.../petstore-go/sdk/specforge-version.json | 1 +
.../petstore-go/sdk/validation_middleware.go | 113 +++++++++++
examples/petstore-rust/Cargo.toml | 2 +
examples/petstore-rust/sdk/README.md | 94 ++++++++++
.../petstore-rust/sdk/specforge-version.json | 1 +
examples/petstore-rust/sdk/src/client.rs | 150 ++++++++++++---
examples/petstore-rust/sdk/src/lib.rs | 5 +-
.../sdk/src/validation_middleware.rs | 146 +++++++++++++++
.../petstore-ts/sdk/specforge-version.json | 1 +
examples/petstore-ts/sdk/src/client.ts | 68 ++++++-
examples/petstore-ts/sdk/src/index.ts | 2 +
examples/petstore-ts/sdk/src/ratelimit.ts | 83 +++++++++
.../petstore-ts/sdk/src/service_container.ts | 46 +++++
examples/petstore-ts/sdk/src/telemetry.ts | 69 +++++++
examples/petstore-ts/sdk/src/validate.ts | 12 +-
.../sdk/src/validation-middleware.ts | 145 +++++++++++++++
37 files changed, 1388 insertions(+), 147 deletions(-)
create mode 100644 examples/petstore-go/sdk/specforge-version.json
create mode 100644 examples/petstore-go/sdk/validation_middleware.go
create mode 100644 examples/petstore-rust/sdk/specforge-version.json
create mode 100644 examples/petstore-rust/sdk/src/validation_middleware.rs
create mode 100644 examples/petstore-ts/sdk/specforge-version.json
create mode 100644 examples/petstore-ts/sdk/src/ratelimit.ts
create mode 100644 examples/petstore-ts/sdk/src/service_container.ts
create mode 100644 examples/petstore-ts/sdk/src/telemetry.ts
create mode 100644 examples/petstore-ts/sdk/src/validation-middleware.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd503a0..a8440d6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
+## [1.6.1] — 2026-07-30
+
+### Fixed
+
+#### Rust emitter
+- **Generated SDK now compiles** — fixed six template bugs that silently broke every generated Rust SDK since the interceptors/validation_middleware/ServiceContainer modules were added in 1.6.0: a response-interceptor loop nested inside the response-transformer loop; a missing `logger` field on `ClientBuilder`; telemetry call-site signature mismatches (`&Method`/`Duration` vs `&str`/`u128`); a two-arg `Result` used where the crate alias is one-arg; a `do_once` parameter-order mismatch; and `serde_json::to_vec` moving instead of borrowing. The example SDK was stale and never regenerated to catch this.
+- **Generated README expanded** — `emit_readme` now documents interceptors, runtime validation (`validation_middleware`), and the `ServiceContainer` telemetry/DI helpers.
+
+#### TypeScript emitter
+- **Telemetry is now wired** — the `TelemetryHooks` interface and `MetricsCollector` were generated but never stored or invoked by `ApiClient`. The client now reads `opts.telemetry`/`container.telemetry` and calls `onRequestStart`/`onRequestEnd`/`onRequestError`/`onRetry`/`onCacheHit`/`onCacheMiss` at the right points in the request lifecycle.
+- **Per-model validators typecheck** — generated object validators called `validateValue` with three arguments (missing the `errors` accumulator) and returned its `void` result as `ValidationError[]`. Now mirrors the enum-validator pattern.
+
+#### Testing
+- **Compile-the-generated-SDK regression tests** — added to the Rust (`cargo check`), TypeScript (`tsc --noEmit`), and Go (`go build ./...`) emitters. These regenerate the SDK into a temp crate and build it, so future emitter template breakage fails CI instead of shipping silently. All three emitters verified against both `fixtures/` specs (petstore + sample-api); each test skips cleanly only when its toolchain is genuinely absent.
+
+---
+
## [1.6.0] — 2026-07-27
### Added
@@ -262,6 +279,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Version Links
+- [v1.6.1](https://github.com/amafjarkasi/specforge-openapi-sdk-codegen/releases/tag/v1.6.1)
- [v1.6.0](https://github.com/amafjarkasi/specforge-openapi-sdk-codegen/releases/tag/v1.6.0)
- [v1.5.0](https://github.com/amafjarkasi/specforge-openapi-sdk-codegen/releases/tag/v1.5.0)
- [v1.4.0](https://github.com/amafjarkasi/specforge-openapi-sdk-codegen/releases/tag/v1.4.0)
diff --git a/README.md b/README.md
index 27f8187..b09440f 100644
--- a/README.md
+++ b/README.md
@@ -680,17 +680,24 @@ sdk-rs/
├── README.md
└── src/
├── lib.rs
- ├── client.rs # Client + ClientBuilder + request_stream
- ├── error.rs
- ├── retry.rs
- ├── paginate.rs
- ├── concurrency.rs
- ├── dedup.rs
- ├── middleware.rs
- ├── idempotency.rs
- ├── streaming.rs # SseStream
- ├── models.rs
- └── api/.rs
+ ├── api/ # one module per tag, one fn per operation
+ ├── cache.rs # ETag/conditional GET response cache
+ ├── client.rs # Client + ClientBuilder + ServiceContainer + request_stream
+ ├── concurrency.rs # async in-flight semaphore
+ ├── dedup.rs # coalesce identical in-flight safe requests
+ ├── error.rs # typed Error enum
+ ├── idempotency.rs # auto Idempotency-Key for safe retries
+ ├── interceptors.rs # request/response body interceptors
+ ├── logging.rs # pluggable Logger trait + Console/Noop impls
+ ├── middleware.rs # request/response middleware chain
+ ├── models.rs # one struct/enum per schema + validators
+ ├── paginate.rs # cursor/offset pagination helpers
+ ├── ratelimit.rs # token-bucket + sliding-window rate limiters
+ ├── retry.rs # exponential backoff + jitter
+ ├── streaming.rs # SseStream for server-sent events
+ ├── telemetry.rs # TelemetryHooks trait + MetricsCollector
+ ├── validate.rs # generated per-model validators
+ └── validation_middleware.rs # schema validation as middleware
```
diff --git a/crates/specforge-core/src/changelog.rs b/crates/specforge-core/src/changelog.rs
index 7085e17..2422942 100644
--- a/crates/specforge-core/src/changelog.rs
+++ b/crates/specforge-core/src/changelog.rs
@@ -590,7 +590,7 @@ fn render_markdown(result: &ChangelogResult, opts: &ChangelogOptions) -> String
let mut out = String::new();
// Header
- out.push_str(&format!("# Changelog\n\n"));
+ out.push_str(&"# Changelog\n\n".to_string());
out.push_str(&format!("## [{version}] -- {today}\n\n"));
// Suggested version bump
@@ -602,9 +602,7 @@ fn render_markdown(result: &ChangelogResult, opts: &ChangelogOptions) -> String
}
// Summary
- out.push_str(&format!(
- "### Summary\n\n"
- ));
+ out.push_str(&"### Summary\n\n".to_string());
out.push_str(&format!(
"- **{}** operation(s) changed\n",
result.operation_entries.len()
diff --git a/crates/specforge-core/src/deprecation.rs b/crates/specforge-core/src/deprecation.rs
index 6539c5f..25e90de 100644
--- a/crates/specforge-core/src/deprecation.rs
+++ b/crates/specforge-core/src/deprecation.rs
@@ -189,7 +189,7 @@ fn extract_alternative(text: &str) -> Option {
let after = &text[pos + pattern.len()..];
// Take until end of sentence or end of text.
let end = after
- .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';')
+ .find(['.', ',', '\n', ';'])
.unwrap_or(after.len());
let alt = after[..end].trim();
if !alt.is_empty() {
@@ -202,7 +202,7 @@ fn extract_alternative(text: &str) -> Option {
if let Some(pos) = lower.find("migrate to ") {
let after = &text[pos + 11..];
let end = after
- .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';')
+ .find(['.', ',', '\n', ';'])
.unwrap_or(after.len());
let alt = after[..end].trim();
if !alt.is_empty() {
diff --git a/crates/specforge-core/src/diff.rs b/crates/specforge-core/src/diff.rs
index 505a942..5c1b799 100644
--- a/crates/specforge-core/src/diff.rs
+++ b/crates/specforge-core/src/diff.rs
@@ -69,7 +69,7 @@ fn ir_major_version(version: &str) -> u64 {
.unwrap_or(0)
}
fn diff_operations(old: &Document, new: &Document, findings: &mut Vec) { let old_ops: std::collections::HashMap = old.operations.iter().map(|op| (op.operation_id.clone(), op)).collect(); let new_ops: std::collections::HashMap = new.operations.iter().map(|op| (op.operation_id.clone(), op)).collect(); for id in old_ops.keys() { if !new_ops.contains_key(id) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("operation removed: {id}"), path: format!("operations.{id}") }); } } for id in new_ops.keys() { if !old_ops.contains_key(id) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("operation added: {id}"), path: format!("operations.{id}") }); } } for id in old_ops.keys() { if let (Some(o), Some(n)) = (old_ops.get(id), new_ops.get(id)) { diff_operation(o, n, findings); } } }
-fn diff_operation(old: &Operation, new: &Operation, findings: &mut Vec) { let path = format!("operations.{}", old.operation_id); for p in &new.parameters { if p.required && !old.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("new required parameter: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } for p in &old.parameters { if !new.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("parameter removed: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } if new.request_body.is_some() && old.request_body.is_none() { if new.request_body.as_ref().map(|rb| rb.required).unwrap_or(false) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: "new required request body".into(), path: format!("{path}.requestBody") }); } } for r in &old.responses { if !new.responses.iter().any(|x| x.status == r.status) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("response removed: {}", r.status), path: format!("{path}.responses.{}", r.status) }); } } }
+fn diff_operation(old: &Operation, new: &Operation, findings: &mut Vec) { let path = format!("operations.{}", old.operation_id); for p in &new.parameters { if p.required && !old.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("new required parameter: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } for p in &old.parameters { if !new.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("parameter removed: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } if new.request_body.is_some() && old.request_body.is_none() && new.request_body.as_ref().map(|rb| rb.required).unwrap_or(false) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: "new required request body".into(), path: format!("{path}.requestBody") }); } for r in &old.responses { if !new.responses.iter().any(|x| x.status == r.status) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("response removed: {}", r.status), path: format!("{path}.responses.{}", r.status) }); } } }
fn diff_schemas(old: &Document, new: &Document, findings: &mut Vec, schema_diffs: &mut Vec) { let old_s: std::collections::HashSet = old.schemas.iter().map(|(k, _)| k.clone()).collect(); let new_s: std::collections::HashSet = new.schemas.iter().map(|(k, _)| k.clone()).collect(); for n in &old_s { if !new_s.contains(n) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("schema removed: {n}"), path: format!("schemas.{n}") }); } } for n in &new_s { if !old_s.contains(n) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("schema added: {n}"), path: format!("schemas.{n}") }); } } for n in old_s.intersection(&new_s) { if let Some(d) = diff_schema(n, old.schemas.get(n).unwrap(), new.schemas.get(n).unwrap(), findings) { schema_diffs.push(d); } } }
fn diff_schema(name: &str, old: &Model, new: &Model, findings: &mut Vec) -> Option { let (op, np) = match (old, new) { (Model::Object(o), Model::Object(n)) => (&o.properties, &n.properties), _ => return None }; let path = format!("schemas.{name}"); let mut changes = Vec::new(); for p in np { if p.required && !op.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("new required property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::AddedRequired { ty: type_to_string(&p.ty) } }); } } for p in np { if !p.required && !op.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("new optional property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::Added { ty: type_to_string(&p.ty) } }); } } for p in op { if !np.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("property removed: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::Removed { ty: type_to_string(&p.ty) } }); } } for p in op { if let Some(np) = np.iter().find(|x| x.name == p.name) { if type_changed(&p.ty, &np.ty) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("type changed for property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::TypeChanged { old_type: type_to_string(&p.ty), new_type: type_to_string(&np.ty) } }); } else if p.required != np.required { findings.push(DiffFinding { severity: if np.required { DiffSeverity::Breaking } else { DiffSeverity::Info }, message: format!("required changed for property: {} ({} → {})", p.name, if p.required { "required" } else { "optional" }, if np.required { "required" } else { "optional" }), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::RequiredChanged { old_required: p.required, new_required: np.required } }); } } } if changes.is_empty() { None } else { Some(SchemaDiffDetail { name: name.to_string(), changes }) } }
fn type_changed(old: &Type, new: &Type) -> bool { match (old, new) { (Type::Scalar(a), Type::Scalar(b)) => a != b, (Type::Reference { name: a, .. }, Type::Reference { name: b, .. }) => a != b, (Type::StringEnum { variants: a, .. }, Type::StringEnum { variants: b, .. }) => a != b, (Type::Array { item: a, .. }, Type::Array { item: b, .. }) => type_changed(a, b), _ => std::mem::discriminant(old) != std::mem::discriminant(new) } }
diff --git a/crates/specforge-core/src/mock.rs b/crates/specforge-core/src/mock.rs
index 08f160a..934256e 100644
--- a/crates/specforge-core/src/mock.rs
+++ b/crates/specforge-core/src/mock.rs
@@ -121,7 +121,7 @@ fn parse_request_line(
) -> Option<(String, String)> {
let mut line = String::new();
reader.read_line(&mut line).ok()?;
- let mut parts = line.trim().split_whitespace();
+ let mut parts = line.split_whitespace();
let method = parts.next()?.to_uppercase();
let path = parts.next()?.to_string();
Some((method, path))
diff --git a/crates/specforge-core/src/profiler.rs b/crates/specforge-core/src/profiler.rs
index 12a19e4..8ace5b0 100644
--- a/crates/specforge-core/src/profiler.rs
+++ b/crates/specforge-core/src/profiler.rs
@@ -365,7 +365,7 @@ pub fn format_text(report: &ProfileReport) -> String {
out.push_str(&format!(" Requests: {}\n", r.total_requests));
out.push_str(&format!(" Success/Error: {} / {}\n", r.success_count, r.error_count));
out.push_str(&format!(" Error rate: {:.1}%\n", r.error_rate * 100.0));
- out.push_str(&format!(" Latency (ms):\n"));
+ out.push_str(&" Latency (ms):\n".to_string());
out.push_str(&format!(" avg: {:.1}\n", r.avg_latency_ms));
out.push_str(&format!(" p50: {:.1}\n", r.p50_latency_ms));
out.push_str(&format!(" p95: {:.1}\n", r.p95_latency_ms));
@@ -425,7 +425,7 @@ pub fn format_markdown(report: &ProfileReport) -> String {
out.push_str(&format!("**Overall throughput:** {:.1} rps\n\n", report.overall_throughput_rps));
if !report.recommendations.is_empty() {
- out.push_str(&format!("## Recommendations\n\n"));
+ out.push_str(&"## Recommendations\n\n".to_string());
for (i, rec) in report.recommendations.iter().enumerate() {
out.push_str(&format!("{}. {}\n", i + 1, rec));
}
diff --git a/crates/specforge-core/src/resolve.rs b/crates/specforge-core/src/resolve.rs
index cb1e284..a4b6712 100644
--- a/crates/specforge-core/src/resolve.rs
+++ b/crates/specforge-core/src/resolve.rs
@@ -19,7 +19,7 @@ use crate::error::ResolveError;
use crate::ir::{
Composition, CompositionKind, Discriminator, Document, EnumModel, EnumVariant, HttpMethod,
Model, ObjectModel, Operation, Parameter as IrParameter, ParamLocation, Property, RequestBody,
- Response, RetryPolicy, Scalar, SchemaRegistry, SecurityScheme, Type, Webhook, IR_VERSION,
+ Response, Scalar, SchemaRegistry, SecurityScheme, Type, Webhook, IR_VERSION,
};
/// Load and fully resolve a parsed OpenAPI document into the IR.
@@ -493,7 +493,7 @@ fn type_to_ir(t: &OApiType, nullable: bool) -> Type {
let item = arr
.items
.as_ref()
- .map(|i| ref_or_boxed_schema_to_type(i))
+ .map(ref_or_boxed_schema_to_type)
.unwrap_or(Type::Unknown);
Type::Array {
item: Box::new(item),
@@ -789,7 +789,7 @@ fn json_body(content: &IndexMap) -> Option {
content
.get("application/json")
.and_then(|m| m.schema.as_ref())
- .map(|s| ref_or_schema_to_type(s))
+ .map(ref_or_schema_to_type)
}
fn ref_or_schema_to_type(schema_or: &ReferenceOr) -> Type {
diff --git a/crates/specforge-core/src/security.rs b/crates/specforge-core/src/security.rs
index ca38832..dc2afdf 100644
--- a/crates/specforge-core/src/security.rs
+++ b/crates/specforge-core/src/security.rs
@@ -12,7 +12,7 @@ pub struct OperationSecurity { pub operation_id: String, pub method: String, pub
pub struct SecurityIssue { pub severity: String, pub message: String, pub path: String }
pub fn analyze_security(doc: &Document) -> SecurityReport {
- let schemes: Vec = doc.security.iter().map(|s| scheme_info_from_ir(s)).collect();
+ let schemes: Vec = doc.security.iter().map(scheme_info_from_ir).collect();
let has_global_auth = !doc.security.is_empty();
let operations = doc.operations.iter().map(|op| OperationSecurity {
operation_id: op.operation_id.clone(), method: op.method.as_str().to_string(), path: op.path.clone(),
diff --git a/crates/specforge-core/src/validate.rs b/crates/specforge-core/src/validate.rs
index 698a5fe..31e6a3d 100644
--- a/crates/specforge-core/src/validate.rs
+++ b/crates/specforge-core/src/validate.rs
@@ -180,14 +180,13 @@ fn validate_scalar(
});
}
}
- Scalar::Boolean => {
- if !value.is_boolean() {
+ Scalar::Boolean
+ if !value.is_boolean() => {
errors.push(ValidationError {
path: path.to_string(),
message: format!("expected boolean, got {}", value_type_name(value)),
});
}
- }
_ => {}
}
}
diff --git a/crates/specforge-core/src/verify.rs b/crates/specforge-core/src/verify.rs
index d27920b..7440bc2 100644
--- a/crates/specforge-core/src/verify.rs
+++ b/crates/specforge-core/src/verify.rs
@@ -132,7 +132,7 @@ fn verify_operation(
Ok(v) => v,
Err(_) => {
// Non-JSON body – we cannot schema-validate but treat as info.
- if expected_status_code >= 200 && expected_status_code < 300 {
+ if (200..300).contains(&expected_status_code) {
// Expected JSON but got something else.
issues.push("response body is not valid JSON".to_string());
schema_match = false;
diff --git a/crates/specforge-go/src/lib.rs b/crates/specforge-go/src/lib.rs
index 268ba1f..2025162 100644
--- a/crates/specforge-go/src/lib.rs
+++ b/crates/specforge-go/src/lib.rs
@@ -298,7 +298,7 @@ fn is_struct_model(o: &ObjectModel) -> bool {
fn emit_models(pkg: &str, doc: &Document) -> String {
let mut out = String::new();
- out.push_str(&format!("// Code generated by specforge. DO NOT EDIT.\n\n"));
+ out.push_str(&"// Code generated by specforge. DO NOT EDIT.\n\n".to_string());
out.push_str(&format!("package {pkg}\n\n"));
// Check if any model needs json + fmt imports: discriminated unions use
@@ -2284,20 +2284,14 @@ func (e ValidationErrors) Error() string {
out.push_str(&format!(
"\t\tif val, exists := obj[{field_lit}]; exists {{\n"
));
- out.push_str(&format!(
- "\t\t\tif s, ok := val.(string); ok {{\n"
- ));
- out.push_str(&format!(
- "\t\t\t\tvalid := false\n"
- ));
+ out.push_str(&"\t\t\tif s, ok := val.(string); ok {\n".to_string());
+ out.push_str(&"\t\t\t\tvalid := false\n".to_string());
for allowed in &vals {
out.push_str(&format!(
"\t\t\t\tif s == {allowed} {{ valid = true }}\n"
));
}
- out.push_str(&format!(
- "\t\t\t\tif !valid {{\n"
- ));
+ out.push_str(&"\t\t\t\tif !valid {\n".to_string());
out.push_str(&format!(
"\t\t\t\t\terrs = append(errs, ValidationError{{Path: {field_lit}, Message: fmt.Sprintf(\"invalid enum value: %s\", s)}})\n"
));
@@ -2560,7 +2554,7 @@ fn emit_method(op: &Operation) -> String {
let (ret_sig, zero_ret) = match &ret_ty {
Some(t) if t.starts_with('[') || t.starts_with("map[") || t == "any" => {
- (format!("({t}, error)"), format!("nil, "))
+ (format!("({t}, error)"), "nil, ".to_string())
}
Some(t) if t.starts_with('*') => (format!("({t}, error)"), "nil, ".into()),
Some(t) => (format!("(*{t}, error)"), "nil, ".into()),
@@ -2889,9 +2883,9 @@ fn zero_check(ty: &Type, ident: &str) -> String {
Type::Scalar(Scalar::Integer) | Type::Scalar(Scalar::Integer64) | Type::Scalar(Scalar::Float) => {
format!("{ident} != 0")
}
- Type::Scalar(Scalar::Boolean | Scalar::Base64 | Scalar::Binary) => format!("{ident}") // only send when true
+ Type::Scalar(Scalar::Boolean | Scalar::Base64 | Scalar::Binary) => ident.to_string() // only send when true
,
- Type::Reference { .. } => format!("true /* always send ref */"),
+ Type::Reference { .. } => "true /* always send ref */".to_string(),
Type::Array { .. } | Type::Map { .. } => format!("len({ident}) > 0"),
_ => "true".into(),
}
@@ -2965,10 +2959,8 @@ fn emit_union_from_map(
out.push_str(&format!("\tcase {}:\n", go_string_lit(&val)));
out.push_str(&format!("\t\tvar v {arm_ty}\n"));
out.push_str("\t\tif b, err := json.Marshal(m); err == nil {\n");
- out.push_str(&format!(
- "\t\t\tif err := json.Unmarshal(b, &v); err == nil {{\n"
- ));
- out.push_str(&format!("\t\t\t\treturn v, nil\n"));
+ out.push_str(&"\t\t\tif err := json.Unmarshal(b, &v); err == nil {\n".to_string());
+ out.push_str(&"\t\t\t\treturn v, nil\n".to_string());
out.push_str("\t\t\t}\n");
out.push_str("\t\t}\n");
out.push_str(&format!("\t\treturn v, fmt.Errorf(\"failed to unmarshal {arm_ty}\")\n"));
@@ -3115,7 +3107,7 @@ fn go_deprecation_alternative(op: &Operation) -> Option {
if let Some(pos) = lower.find(pattern) {
let after = &text[pos + pattern.len()..];
let end = after
- .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';')
+ .find(['.', ',', '\n', ';'])
.unwrap_or(after.len());
let alt = after[..end].trim();
if !alt.is_empty() {
@@ -3142,7 +3134,7 @@ fn go_schema_deprecation_alternative(desc: &str) -> Option {
if let Some(pos) = lower.find(pattern) {
let after = &desc[pos + pattern.len()..];
let end = after
- .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';')
+ .find(['.', ',', '\n', ';'])
.unwrap_or(after.len());
let alt = after[..end].trim();
if !alt.is_empty() {
diff --git a/crates/specforge-go/tests/gen_test.rs b/crates/specforge-go/tests/gen_test.rs
index 152f8c5..b56ed1e 100644
--- a/crates/specforge-go/tests/gen_test.rs
+++ b/crates/specforge-go/tests/gen_test.rs
@@ -14,3 +14,63 @@ fn generate_and_check_docs() {
println!("=== Go doc comment verification PASSED ===");
for line in content.lines().take(40) { println!(" {line}"); }
}
+
+/// Regression test: the generated Go SDK must compile.
+///
+/// Template bugs in the emitter only surface when the generated `.go` is built.
+/// This test regenerates the SDK into an isolated temp dir and runs
+/// `go build ./...`, so any future emitter breakage fails CI. It skips cleanly
+/// when the Go toolchain is absent (e.g. in environments without Go installed);
+/// CI matrices that include Go run it for real.
+#[test]
+fn generated_sdk_compiles() {
+ if std::env::var_os("SKIP_COMPILE_TEST").is_some() {
+ eprintln!("note: SKIP_COMPILE_TEST set; skipping generated_sdk_compiles");
+ return;
+ }
+ // Resolve the Go binary. Prefer `go` on PATH, then the standard install
+ // location (matching scripts/generate-examples.sh, which adds
+ // /usr/local/go/bin). Skip gracefully if neither is runnable.
+ let go = ["go", "/usr/local/go/bin/go"]
+ .iter()
+ .find_map(|candidate| {
+ let ok = std::process::Command::new(candidate)
+ .arg("version")
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false);
+ if ok { Some(*candidate) } else { None }
+ });
+ let go = match go {
+ Some(g) => g,
+ None => {
+ eprintln!("note: go toolchain not available; skipping generated_sdk_compiles");
+ return;
+ }
+ };
+
+ let spec_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/sample-api.yaml");
+ let spec = specforge_core::parse_file(&spec_path).expect("parse");
+ let doc = specforge_core::resolve(&spec).expect("resolve");
+ let go_dir = tempfile::tempdir().unwrap();
+ let opts = specforge_go::GeneratorOptions {
+ out_dir: go_dir.path().to_path_buf(),
+ module_path: Some("example.com/generated".to_string()),
+ package_name: None,
+ i18n: None,
+ };
+ let files = specforge_go::generate(&doc, &opts).expect("emit Go");
+ assert!(!files.is_empty(), "emitter produced no files");
+
+ // The generated SDK uses only the standard library, so this builds offline.
+ let status = std::process::Command::new(go)
+ .args(["build", "./..."])
+ .current_dir(go_dir.path())
+ .status()
+ .expect("failed to spawn go build");
+
+ assert!(
+ status.success(),
+ "generated Go SDK failed to compile (`go build ./...` exited {status})"
+ );
+}
diff --git a/crates/specforge-rust/src/lib.rs b/crates/specforge-rust/src/lib.rs
index 8fec920..461de78 100644
--- a/crates/specforge-rust/src/lib.rs
+++ b/crates/specforge-rust/src/lib.rs
@@ -253,11 +253,10 @@ fn snake(input: &str) -> String {
out.extend(ch.to_lowercase());
} else if ch.is_ascii_alphanumeric() {
out.push(ch.to_ascii_lowercase());
- } else if ch == '_' {
- if !out.ends_with('_') {
+ } else if ch == '_'
+ && !out.ends_with('_') {
out.push('_');
}
- }
}
let out = out.trim_matches('_').to_string();
let out = if out.is_empty() {
@@ -1641,7 +1640,7 @@ use crate::cache::ResponseCache;
None
}};
let body_bytes = match body {{
- Some(b) => Some(serde_json::to_vec(b)?),
+ Some(b) => Some(serde_json::to_vec(&b)?),
None => None,
}};
@@ -1781,15 +1780,15 @@ use crate::cache::ResponseCache;
}}
// Apply response transformers.
let mut value: serde_json::Value = serde_json::from_slice(&data)?;
- for t in &self.response_transformers {{
- value = t.transform(value);
+ for t in &self.response_transformers {{
+ value = t.transform(value);
+ }}
- // Apply response interceptors.
- for i in &self.response_interceptors {{
- value = i.transform(value);
- }}
- }}
- Ok(serde_json::from_value(value)?)
+ // Apply response interceptors.
+ for i in &self.response_interceptors {{
+ value = i.transform(value);
+ }}
+ Ok(serde_json::from_value(value)?)
}}
/// Issue a streaming request (no retry). Returns the raw Response; use
@@ -1915,10 +1914,10 @@ use crate::cache::ResponseCache;
if let Some(l) = &self.logger {{
l.warn(&format!("[retry] {{}} {{}} error={{}}, attempt {{}}/{{}}", method.as_str(), url, last_err.as_ref().unwrap(), attempt + 2, max + 1));
}}
- // Telemetry: retry notification.
- if let Some(t) = &self.telemetry {{
- t.on_retry(method, url, attempt + 1, &last_err.as_ref().unwrap());
- }}
+ // Telemetry: retry notification.
+ if let Some(t) = &self.telemetry {{
+ t.on_retry(method.as_str(), url, attempt + 1, &last_err.as_ref().unwrap());
+ }}
}}
}}
}}
@@ -1929,14 +1928,13 @@ use crate::cache::ResponseCache;
&self,
method: &reqwest::Method,
url: &str,
- #[allow(unused_variables)]
- start: std::time::Instant,
query: &[(String, String)],
body_bytes: Option<&[u8]>,
idem_key: Option<&str>,
if_none_match: Option<&str>,
+ start: std::time::Instant,
) -> Result<(Vec, u16, String)> {{
- let _start = std::time::Instant::now();
+ let _start = start;
// Build full URL with query for middleware visibility.
let full_url = if query.is_empty() {{
@@ -2024,15 +2022,16 @@ use crate::cache::ResponseCache;
if mw_res.status == 304 {{
return Ok((mw_res.body, mw_res.status, etag));
}}
- // Telemetry: request error.
- if let Some(t) = &self.telemetry {{
- t.on_request_error(method, url, _start.elapsed(), &format!("HTTP {{}}", mw_res.status));
- }}
- return Err(Error::Http {{
+ let err = Error::Http {{
status: mw_res.status,
body: String::from_utf8_lossy(&mw_res.body).into_owned(),
url: full_url,
- }});
+ }};
+ // Telemetry: request error.
+ if let Some(t) = &self.telemetry {{
+ t.on_request_error(method.as_str(), url, _start.elapsed().as_millis(), &err);
+ }}
+ return Err(err);
}}
// Logger: response received.
if let Some(l) = &self.logger {{
@@ -2041,7 +2040,7 @@ use crate::cache::ResponseCache;
// Telemetry: successful request.
if let Some(t) = &self.telemetry {{
- t.on_request_end(method, url, _start.elapsed(), mw_res.status);
+ t.on_request_end(method.as_str(), url, _start.elapsed().as_millis(), mw_res.status);
}}
Ok((mw_res.body, mw_res.status, etag))
}}
@@ -2092,6 +2091,7 @@ use crate::cache::ResponseCache;
cache: Option,
rate_limiter: Option>,
telemetry: Option>,
+ logger: Option>,
middlewares: Vec,
stream_middlewares: Vec,
/// Seeded from the first API-key security scheme in the spec, if any.
@@ -2133,10 +2133,11 @@ use crate::cache::ResponseCache;
dedupe: true,
idempotency: true,
validation: false,
- cache: None,
- rate_limiter: None,
- telemetry: None,
- response_transformers: Vec::new(),
+ cache: None,
+ rate_limiter: None,
+ telemetry: None,
+ logger: None,
+ response_transformers: Vec::new(),
request_interceptors: Vec::new(),
response_interceptors: Vec::new(),
middlewares: Vec::new(),
@@ -2463,9 +2464,7 @@ fn value_type_name(v: &Value) -> &'static str {
out.push_str(" if !v.is_object() {\n");
out.push_str(" errors.push(ValidationError {\n");
out.push_str(" path: String::new(),\n");
- out.push_str(&format!(
- " message: format!(\"expected object, got {{}}\", value_type_name(v)),\n"
- ));
+ out.push_str(&" message: format!(\"expected object, got {}\", value_type_name(v)),\n".to_string());
out.push_str(" });\n");
out.push_str(" }\n");
} else {
@@ -2561,9 +2560,9 @@ use crate::validate::ValidationError;
/// Schema descriptor for an endpoint's request and/or response body validator.
pub struct EndpointSchema {
/// Validator function for the request body. None means no validation.
- pub request_body: Option Result<(), Vec>>,
+ pub request_body: Option std::result::Result<(), Vec>>,
/// Validator function for the response body. None means no validation.
- pub response_body: Option Result<(), Vec>>,
+ pub response_body: Option std::result::Result<(), Vec>>,
}
/// Route schema map keyed by "METHOD /path/pattern".
@@ -2707,7 +2706,7 @@ use serde::{Deserialize, Serialize};
// Payload structs for each webhook.
for wh in &doc.webhooks {
- let name = pascal(&format!("{}WebhookPayload", &wh.name));
+ let name = pascal(&format!("{}WebhookPayload", wh.name));
if let Some(d) = &wh.description {
out.push_str(&rust_doc(d));
} else if let Some(s) = &wh.summary {
@@ -2729,8 +2728,8 @@ use serde::{Deserialize, Serialize};
out.push_str("/// Trait for handling webhook events.\n");
out.push_str("pub trait WebhookHandler: Send + Sync {\n");
for wh in &doc.webhooks {
- let payload_ty = pascal(&format!("{}WebhookPayload", &wh.name));
- let method_name = snake(&format!("handle_{}", &wh.name));
+ let payload_ty = pascal(&format!("{}WebhookPayload", wh.name));
+ let method_name = snake(&format!("handle_{}", wh.name));
out.push_str(&format!(
" /// Handle the `{}` webhook.\n",
wh.name
@@ -2825,7 +2824,7 @@ fn emit_enum(e: &EnumModel) -> String {
fn emit_object(o: &ObjectModel, registry: &specforge_core::SchemaRegistry) -> String {
let name = pascal(&o.name);
let mut out = String::new();
- let is_deprecated = o.description.as_deref().map_or(false, |d| {
+ let is_deprecated = o.description.as_deref().is_some_and(|d| {
d.to_lowercase().contains("deprecated")
});
if let Some(d) = &o.description {
@@ -3501,7 +3500,7 @@ fn rust_deprecation_alternative(op: &Operation) -> Option {
if let Some(pos) = lower.find(pattern) {
let after = &text[pos + pattern.len()..];
let end = after
- .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';')
+ .find(['.', ',', '\n', ';'])
.unwrap_or(after.len());
let alt = after[..end].trim();
if !alt.is_empty() {
@@ -3528,7 +3527,7 @@ fn rust_schema_deprecation_alternative(desc: &str) -> Option {
if let Some(pos) = lower.find(pattern) {
let after = &desc[pos + pattern.len()..];
let end = after
- .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';')
+ .find(['.', ',', '\n', ';'])
.unwrap_or(after.len());
let alt = after[..end].trim();
if !alt.is_empty() {
@@ -3713,6 +3712,100 @@ let client = Client::builder()
.build()?;
```
+## Interceptors
+
+Transform the request body before it is serialized, or the response body after it
+is deserialized, by implementing a trait and registering it on the builder:
+
+```rust
+use {crate_name}::{{RequestInterceptor, ResponseInterceptor}};
+use serde_json::{{json, Value}};
+
+// Add a field to every outgoing request body.
+struct AddTraceId;
+impl RequestInterceptor for AddTraceId {{
+ fn transform(&self, mut body: Value) -> Value {{
+ if let Some(obj) = body.as_object_mut() {{
+ obj.insert("trace_id".into(), json!("abc-123"));
+ }}
+ body
+ }}
+}}
+
+// Strip a field from every response body.
+struct StripMeta;
+impl ResponseInterceptor for StripMeta {{
+ fn transform(&self, mut body: Value) -> Value {{
+ if let Some(obj) = body.as_object_mut() {{
+ obj.remove("_meta");
+ }}
+ body
+ }}
+}}
+
+let client = Client::builder()
+ .bearer_token("…")
+ .request_interceptor(AddTraceId)
+ .response_interceptor(StripMeta)
+ .build()?;
+```
+
+## Validation
+
+Validate request/response bodies against the OpenAPI schema at runtime. Toggle the
+built-in validator per client, or wire the generated `validation_middleware` into the
+middleware chain with per-route schemas for finer control:
+
+```rust
+// Whole-client validation (uses the generated validators automatically).
+let client = Client::builder()
+ .bearer_token("…")
+ .validation(true)
+ .build()?;
+
+// Or attach validation as a middleware with an explicit route -> schema map.
+use std::collections::HashMap;
+use {crate_name}::validation_middleware::{{validation_middleware, EndpointSchema, RouteSchemaMap}};
+use {crate_name}::models; // generated validators live here, e.g. validate_pet
+
+let mut schemas: RouteSchemaMap = HashMap::new();
+schemas.insert(
+ "POST /pets".to_string(),
+ EndpointSchema {{
+ request_body: Some(models::validate_pet),
+ response_body: None,
+ }},
+);
+let mut client = Client::builder().bearer_token("…").build()?;
+client.use_middleware(validation_middleware(schemas));
+```
+
+## Telemetry & dependency injection
+
+Observe the request lifecycle by implementing `TelemetryHooks`, or group injectable
+dependencies (HTTP client, cache, rate limiter, logger, telemetry) into a
+`ServiceContainer` and apply them in one call:
+
+```rust
+use std::time::Duration;
+use {crate_name}::{{MetricsCollector, ServiceContainer}};
+
+let metrics = MetricsCollector::default();
+
+let sc = ServiceContainer::new()
+ .cache_ttl(Duration::from_secs(60))
+ .logger({crate_name}::ConsoleLogger)
+ .telemetry(metrics.clone());
+
+let client = Client::builder()
+ .bearer_token("…")
+ .service_container(sc)
+ .build()?;
+
+// `metrics.get_metrics().await` now reflects every request, retry, and error.
+println!("{{:?}}", metrics.get_metrics().await);
+```
+
_Do not edit generated files directly._
"#,
title = doc.title,
diff --git a/crates/specforge-rust/tests/gen_test.rs b/crates/specforge-rust/tests/gen_test.rs
index ae5ccd0..4fec5bb 100644
--- a/crates/specforge-rust/tests/gen_test.rs
+++ b/crates/specforge-rust/tests/gen_test.rs
@@ -14,3 +14,66 @@ fn generate_and_check_docs() {
println!("=== Rust doc comment verification PASSED ===");
for line in content.lines().take(40) { println!(" {line}"); }
}
+
+/// Regression test: the generated Rust SDK must actually compile.
+///
+/// Earlier emitter changes (interceptors/validation_middleware modules,
+/// ServiceContainer) shipped without ever compiling the output, so template
+/// bugs (nested loops, missing struct fields, trait signature mismatches,
+/// wrong arg order) silently broke every generated SDK. This test regenerates
+/// the SDK into an isolated temp crate and runs `cargo check` on it, so any
+/// future emitter template breakage fails CI.
+#[test]
+fn generated_sdk_compiles() {
+ // Skip in environments without network/cargo (e.g. some CI sandboxes);
+ // requires the rust toolchain and the ability to fetch deps.
+ if std::env::var_os("SKIP_COMPILE_TEST").is_some() {
+ eprintln!("note: SKIP_COMPILE_TEST set; skipping generated_sdk_compiles");
+ return;
+ }
+ let cargo = std::process::Command::new("cargo")
+ .arg("--version")
+ .output();
+ if !matches!(cargo, Ok(o) if o.status.success()) {
+ eprintln!("note: cargo not available; skipping generated_sdk_compiles");
+ return;
+ }
+
+ let spec_path =
+ std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/sample-api.yaml");
+ let spec = specforge_core::parse_file(&spec_path).expect("parse");
+ let doc = specforge_core::resolve(&spec).expect("resolve");
+
+ let rust_dir = tempfile::tempdir().unwrap();
+ let opts = specforge_rust::GeneratorOptions {
+ out_dir: rust_dir.path().to_path_buf(),
+ crate_name: None,
+ i18n: None,
+ };
+ let files = specforge_rust::generate(&doc, &opts).expect("emit Rust");
+ assert!(!files.is_empty(), "emitter produced no files");
+
+ // The generated Cargo.toml is a leaf package; declare an empty workspace so
+ // `cargo check` from this temp dir isn't absorbed into specforge's workspace.
+ let cargo_toml = rust_dir.path().join("Cargo.toml");
+ let mut manifest = std::fs::read_to_string(&cargo_toml).expect("read generated Cargo.toml");
+ if !manifest.contains("[workspace]") {
+ manifest.push_str("\n[workspace]\n");
+ }
+ std::fs::write(&cargo_toml, manifest).expect("write generated Cargo.toml");
+
+ // `cargo check` the generated crate. Use the same toolchain, offline-friendly:
+ // deps are fetched on demand; this is the real signal we want.
+ let status = std::process::Command::new("cargo")
+ .arg("check")
+ .arg("--quiet")
+ .current_dir(rust_dir.path())
+ .env("CARGO_TARGET_DIR", rust_dir.path().join("target"))
+ .status()
+ .expect("failed to spawn cargo check");
+
+ assert!(
+ status.success(),
+ "generated Rust SDK failed to compile (`cargo check` exited {status})"
+ );
+}
diff --git a/crates/specforge-ts/src/operations.rs b/crates/specforge-ts/src/operations.rs
index e75fa8b..617266f 100644
--- a/crates/specforge-ts/src/operations.rs
+++ b/crates/specforge-ts/src/operations.rs
@@ -240,11 +240,11 @@ fn emit_method(_doc: &Document, op: &Operation, refs: &mut TypeRefs) -> (Option<
// Determine validator names for request body and response body.
let req_validator = op.request_body.as_ref().and_then(|b| validator_name(&b.ty));
- let resp_validator = success.as_ref().and_then(|t| validator_name(t));
+ let resp_validator = success.as_ref().and_then(validator_name);
// Schema names for the generated code (e.g. "PetSchema", "StatusSchema").
let req_schema_name = op.request_body.as_ref().and_then(|b| schema_name(&b.ty));
- let resp_schema_name = success.as_ref().and_then(|t| schema_name(t));
+ let resp_schema_name = success.as_ref().and_then(schema_name);
let mut used_validators = Vec::new();
if let Some(v) = &req_validator {
diff --git a/crates/specforge-ts/src/runtime.rs b/crates/specforge-ts/src/runtime.rs
index 1bae022..5375a11 100644
--- a/crates/specforge-ts/src/runtime.rs
+++ b/crates/specforge-ts/src/runtime.rs
@@ -789,9 +789,11 @@ export interface ValidationError {
"export function validate{model}(value: unknown, path = \"\"): ValidationError[] {{\n",
model = name
));
+ out.push_str(" const errors: ValidationError[] = [];\n");
out.push_str(&format!(
- " return validateValue(value, {name}Schema, path);\n"
+ " validateValue(value, {name}Schema, path, errors);\n"
));
+ out.push_str(" return errors;\n");
out.push_str("}\n\n");
}
Model::Enum(e) => {
@@ -1538,6 +1540,7 @@ import {{ Middleware, MiddlewareRequest, composeMiddleware }} from "./middleware
import {{ validateRequest, validateResponse, type SchemaType, type ValidationError }} from "./validate";
import {{ ResponseCache }} from "./cache";
import {{ type Logger, ConsoleLogger }} from "./logging";
+import {{ type TelemetryHooks }} from "./telemetry";
import {{ type RequestInterceptor, type ResponseInterceptor }} from "./interceptors";
import {{ type ServiceContainer }} from "./service_container";
import {{ createValidationMiddleware, type RouteSchemaMap }} from "./validation-middleware";
@@ -1583,6 +1586,8 @@ export interface ApiClientOptions {{
responseTransformers?: ResponseTransformer[];
/** Structured logger for SDK lifecycle events (requests, retries, cache). */
logger?: Logger;
+ /** Telemetry hooks for request lifecycle observability. Implement any subset. */
+ telemetry?: TelemetryHooks;
/** Request interceptors applied before each request body is serialized. */
requestInterceptors?: RequestInterceptor[];
/** Response interceptors applied after each response body is deserialized. */
@@ -1624,6 +1629,8 @@ export class ApiClient {{
private readonly responseTransformers: ResponseTransformer[];
/** Structured logger for SDK lifecycle events. */
private readonly logger: Logger;
+ /** Optional telemetry hooks for request lifecycle observability. */
+ private readonly telemetry?: TelemetryHooks;
/** Request interceptors applied before each request body is serialized. */
private readonly requestInterceptors: RequestInterceptor[];
/** Response interceptors applied after each response body is deserialized. */
@@ -1647,6 +1654,7 @@ export class ApiClient {{
this.cache = opts.cache ?? c?.cache ?? (opts.cacheTtlMs !== undefined ? new ResponseCache(opts.cacheTtlMs) : undefined);
this.responseTransformers = opts.responseTransformers ?? [];
this.logger = opts.logger ?? c?.logger ?? new ConsoleLogger();
+ this.telemetry = opts.telemetry ?? c?.telemetry;
this.requestInterceptors = opts.requestInterceptors ?? [];
this.responseInterceptors = opts.responseInterceptors ?? [];
if (opts.middleware) for (const m of opts.middleware) this.middlewares.push(m);
@@ -1675,8 +1683,10 @@ export class ApiClient {{
const cachedEntry = isGet && this.cache ? this.cache.get(url) : undefined;
if (cachedEntry) {{
this.logger.debug(`[cache] HIT ${{method}} ${{path}}`);
+ this.telemetry?.onCacheHit?.(method, path);
}} else if (isGet && this.cache) {{
this.logger.debug(`[cache] MISS ${{method}} ${{path}}`);
+ this.telemetry?.onCacheMiss?.(method, path);
}}
const mergedHeaders: Record | undefined =
cachedEntry
@@ -1687,7 +1697,7 @@ export class ApiClient {{
const dispatch = async (): Promise => {{
const release = await this.semaphore.acquire();
try {{
- return await this.dispatchWithRetry(method, url, {{ ...requestOptions, headers: mergedHeaders }});
+ return await this.dispatchWithRetry(method, url, path, {{ ...requestOptions, headers: mergedHeaders }});
}} finally {{
release();
}}
@@ -1733,6 +1743,7 @@ export class ApiClient {{
private async dispatchWithRetry(
method: string,
url: string,
+ path: string,
requestOptions: RequestOptions | undefined,
): Promise {{
const perCallRetry = requestOptions?.retry;
@@ -1760,6 +1771,7 @@ export class ApiClient {{
callerSignal?.addEventListener("abort", onCallerAbort!, {{ once: true }});
this.logger.info(`[request] ${{method}} ${{url}}${{attempt > 0 ? ` (attempt ${{attempt + 1}})` : ""}}`);
+ if (attempt === 0) this.telemetry?.onRequestStart?.(method, path);
// Build the request descriptor for middleware.
const mwReq: MiddlewareRequest = {{
@@ -1787,6 +1799,7 @@ export class ApiClient {{
if (response.ok) {{
this.logger.info(`[response] ${{method}} ${{url}} -> ${{response.status}}`);
+ this.telemetry?.onRequestEnd?.(method, path, Date.now() - startedAt, response.status);
return response;
}}
@@ -1796,27 +1809,37 @@ export class ApiClient {{
this.retry.retryOnStatuses.includes(response.status);
if (shouldRetry) {{
this.logger.warn(`[retry] ${{method}} ${{url}} status=${{response.status}}, attempt ${{attempt + 2}}/${{maxRetries + 1}}`);
+ this.telemetry?.onRetry?.(method, path, attempt + 1, response.status);
await sleep(backoffDelay(attempt, this.retry));
continue;
}}
const body = await parseBody(response);
- throw httpError(response.status, body, url, response.statusText || `HTTP ${{response.status}}`);
+ const httpErr = httpError(response.status, body, url, response.statusText || `HTTP ${{response.status}}`);
+ this.telemetry?.onRequestError?.(method, path, Date.now() - startedAt, httpErr);
+ throw httpErr;
}} catch (e) {{
if (timer) clearTimeout(timer);
callerSignal?.removeEventListener("abort", onCallerAbort!);
+ // ApiError (e.g. the httpErr built above) was already reported via
+ // telemetry before being thrown — re-throw without double-counting.
if (isApiError(e)) throw e;
// Distinguish caller-cancelled from our-timeout from network.
if (callerSignal?.aborted) throw networkError(e, "request aborted by caller");
if (e instanceof DOMException && e.name === "AbortError") {{
- throw timeoutError(Date.now() - startedAt);
+ const err = timeoutError(Date.now() - startedAt);
+ this.telemetry?.onRequestError?.(method, path, Date.now() - startedAt, err);
+ throw err;
}}
if (attempt < maxRetries && retryable) {{
this.logger.warn(`[retry] ${{method}} ${{url}} error=${{(e as Error)?.message}}, attempt ${{attempt + 2}}/${{maxRetries + 1}}`);
+ this.telemetry?.onRetry?.(method, path, attempt + 1, e);
await sleep(backoffDelay(attempt, this.retry));
continue;
}}
- throw networkError(e, (e as Error)?.message ?? "network error");
+ const err = networkError(e, (e as Error)?.message ?? "network error");
+ this.telemetry?.onRequestError?.(method, path, Date.now() - startedAt, err);
+ throw err;
}}
}}
throw configurationError("exhausted retries without a response");
diff --git a/crates/specforge-ts/tests/gen_test.rs b/crates/specforge-ts/tests/gen_test.rs
index 2066728..e4e42ef 100644
--- a/crates/specforge-ts/tests/gen_test.rs
+++ b/crates/specforge-ts/tests/gen_test.rs
@@ -71,3 +71,64 @@ fn generate_version_file() {
println!("=== TS specforge-version.json verification PASSED ===");
println!("Content:\n{content}");
}
+
+/// Regression test: the generated TypeScript SDK must typecheck.
+///
+/// Template bugs in the emitter (e.g. unwired hooks, wrong-arity validator
+/// helpers, broken imports) only surface when the generated `.ts` is compiled.
+/// This test regenerates the SDK into an isolated temp dir, installs its
+/// devDependencies (just `typescript`), and runs `tsc --noEmit`, so any future
+/// emitter breakage fails CI.
+#[test]
+fn generated_sdk_typechecks() {
+ if std::env::var_os("SKIP_COMPILE_TEST").is_some() {
+ eprintln!("note: SKIP_COMPILE_TEST set; skipping generated_sdk_typechecks");
+ return;
+ }
+ // Require node + npm + npx; skip gracefully if absent.
+ for tool in ["node", "npm", "npx"] {
+ let ok = std::process::Command::new(tool)
+ .arg("--version")
+ .output()
+ .map(|o| o.status.success())
+ .unwrap_or(false);
+ if !ok {
+ eprintln!("note: {tool} not available; skipping generated_sdk_typechecks");
+ return;
+ }
+ }
+
+ let spec_path =
+ std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/sample-api.yaml");
+ let spec = specforge_core::parse_file(&spec_path).expect("parse");
+ let doc = specforge_core::resolve(&spec).expect("resolve");
+
+ let ts_dir = tempfile::tempdir().unwrap();
+ let opts = specforge_ts::GeneratorOptions {
+ out_dir: ts_dir.path().to_path_buf(),
+ package_name: None,
+ i18n: None,
+ };
+ let files = specforge_ts::generate(&doc, &opts).expect("emit TS");
+ assert!(!files.is_empty(), "emitter produced no files");
+
+ // Install devDependencies (typescript) without network noise.
+ let install = std::process::Command::new("npm")
+ .args(["install", "--no-audit", "--no-fund", "--loglevel=error"])
+ .current_dir(ts_dir.path())
+ .status()
+ .expect("failed to spawn npm install");
+ assert!(install.success(), "npm install of generated SDK failed");
+
+ // Typecheck. `tsc --noEmit` returns non-zero on any type error.
+ let status = std::process::Command::new("npx")
+ .args(["tsc", "--noEmit"])
+ .current_dir(ts_dir.path())
+ .status()
+ .expect("failed to spawn tsc");
+
+ assert!(
+ status.success(),
+ "generated TypeScript SDK failed to typecheck (`tsc --noEmit` exited {status})"
+ );
+}
diff --git a/docs-site/docs/intro.md b/docs-site/docs/intro.md
index c3ac842..9ffa349 100644
--- a/docs-site/docs/intro.md
+++ b/docs-site/docs/intro.md
@@ -609,17 +609,24 @@ sdk-rs/
├── README.md
└── src/
├── lib.rs
- ├── client.rs # Client + ClientBuilder + request_stream
- ├── error.rs
- ├── retry.rs
- ├── paginate.rs
- ├── concurrency.rs
- ├── dedup.rs
- ├── middleware.rs
- ├── idempotency.rs
- ├── streaming.rs # SseStream
- ├── models.rs
- └── api/.rs
+ ├── api/ # one module per tag, one fn per operation
+ ├── cache.rs # ETag/conditional GET response cache
+ ├── client.rs # Client + ClientBuilder + ServiceContainer + request_stream
+ ├── concurrency.rs # async in-flight semaphore
+ ├── dedup.rs # coalesce identical in-flight safe requests
+ ├── error.rs # typed Error enum
+ ├── idempotency.rs # auto Idempotency-Key for safe retries
+ ├── interceptors.rs # request/response body interceptors
+ ├── logging.rs # pluggable Logger trait + Console/Noop impls
+ ├── middleware.rs # request/response middleware chain
+ ├── models.rs # one struct/enum per schema + validators
+ ├── paginate.rs # cursor/offset pagination helpers
+ ├── ratelimit.rs # token-bucket + sliding-window rate limiters
+ ├── retry.rs # exponential backoff + jitter
+ ├── streaming.rs # SseStream for server-sent events
+ ├── telemetry.rs # TelemetryHooks trait + MetricsCollector
+ ├── validate.rs # generated per-model validators
+ └── validation_middleware.rs # schema validation as middleware
```
diff --git a/docs-site/docs/reference/changelog.md b/docs-site/docs/reference/changelog.md
index 8215d50..963efee 100644
--- a/docs-site/docs/reference/changelog.md
+++ b/docs-site/docs/reference/changelog.md
@@ -12,6 +12,22 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.6.1] — 2026-07-30
+
+### Fixed
+
+#### Rust emitter
+- **Generated SDK now compiles** — fixed six template bugs (nested interceptor/transformer loops, missing `logger` field on `ClientBuilder`, telemetry call-site signature mismatches, wrong `Result` arity, `do_once` parameter-order mismatch, `serde_json::to_vec` move-instead-of-borrow) that broke every generated Rust SDK since the 1.6.0 interceptors/validation_middleware/ServiceContainer additions. The example SDK was stale and had never been regenerated to surface this.
+
+#### TypeScript emitter
+- **Telemetry wired in** — `ApiClient` now stores and invokes the previously-dead `TelemetryHooks` (`onRequestStart`/`onRequestEnd`/`onRequestError`/`onRetry`/`onCacheHit`/`onCacheMiss`).
+- **Per-model validators typecheck** — object validators now pass the `errors` accumulator and return it, matching the enum-validator pattern.
+
+#### Testing
+- **Compile-the-generated-SDK tests** added for the Rust (`cargo check`), TypeScript (`tsc --noEmit`), and Go (`go build ./...`) emitters — regenerates into a temp crate and builds it so template breakage fails CI. All three emitters verified against both `fixtures/` specs; each test skips cleanly only when its toolchain is genuinely absent.
+
+---
+
## [1.2.0] — 2026-07-27
### Added
diff --git a/examples/petstore-go/sdk/client.go b/examples/petstore-go/sdk/client.go
index f2bff5a..a69a9ab 100644
--- a/examples/petstore-go/sdk/client.go
+++ b/examples/petstore-go/sdk/client.go
@@ -72,6 +72,26 @@ type ResponseTransformer interface {
Transform(response any) any
}
+// ServiceContainer groups all DI-able components for easy swapping and testing.
+// Create one with NewServiceContainer, configure the fields you want to override,
+// then pass it to Client.WithServiceContainer to apply them all at once.
+type ServiceContainer struct {
+ HTTPClient *http.Client
+ Cache *ResponseCache
+ RateLimiter RateLimiter
+ Logger Logger
+ Telemetry TelemetryHooks
+}
+
+// NewServiceContainer creates a ServiceContainer with sensible defaults
+// (default HTTP client, console logger). Override individual fields as needed.
+func NewServiceContainer() *ServiceContainer {
+ return &ServiceContainer{
+ HTTPClient: &http.Client{Timeout: 30 * time.Second},
+ Logger: NewConsoleLogger(),
+ }
+}
+
// Client is the generated HTTP client for Swagger Petstore.
type Client struct {
BaseURL string
@@ -244,10 +264,35 @@ func (c *Client) WithHTTPClient(hc *http.Client) *Client {
return c
}
+// WithServiceContainer applies all non-nil fields from a ServiceContainer to the client.
+// Nil fields are left as their current value, so you only need to set what you want to override.
+func (c *Client) WithServiceContainer(sc *ServiceContainer) *Client {
+ if sc == nil {
+ return c
+ }
+ if sc.HTTPClient != nil {
+ c.HTTPClient = sc.HTTPClient
+ }
+ if sc.Cache != nil {
+ c.Cache = sc.Cache
+ }
+ if sc.RateLimiter != nil {
+ c.RateLimiter = sc.RateLimiter
+ }
+ if sc.Logger != nil {
+ c.Logger = sc.Logger
+ }
+ if sc.Telemetry != nil {
+ c.Telemetry = sc.Telemetry
+ }
+ return c
+}
+
// WithResponseTransformers sets response transformers applied in order after JSON decoding.
func (c *Client) WithResponseTransformers(transformers ...ResponseTransformer) *Client {
c.ResponseTransformers = append(c.ResponseTransformers, transformers...)
return c
+}
// WithRequestInterceptors sets request interceptors applied in order before each request body is serialized.
func (c *Client) WithRequestInterceptors(interceptors ...RequestInterceptor) *Client {
@@ -260,7 +305,6 @@ func (c *Client) WithResponseInterceptors(interceptors ...ResponseInterceptor) *
c.ResponseInterceptors = append(c.ResponseInterceptors, interceptors...)
return c
}
-}
// DoJSON issues an HTTP request (concurrency + dedupe + retry + middleware),
// JSON-decodes a successful response into out (when non-nil), and returns a
diff --git a/examples/petstore-go/sdk/specforge-version.json b/examples/petstore-go/sdk/specforge-version.json
new file mode 100644
index 0000000..1b2d9ec
--- /dev/null
+++ b/examples/petstore-go/sdk/specforge-version.json
@@ -0,0 +1 @@
+{"specforge_version":"0.8.0","ir_version":"1.0","spec_version":"1.0.0","generated_at":"2026-07-30T06:02:33Z"}
\ No newline at end of file
diff --git a/examples/petstore-go/sdk/validation_middleware.go b/examples/petstore-go/sdk/validation_middleware.go
new file mode 100644
index 0000000..10e4ea8
--- /dev/null
+++ b/examples/petstore-go/sdk/validation_middleware.go
@@ -0,0 +1,113 @@
+// Code generated by specforge. DO NOT EDIT.
+
+package sdk
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+// EndpointSchema describes the request and/or response schema for an endpoint.
+type EndpointSchema struct {
+ // RequestBody is the schema for the request body. Nil means no validation.
+ RequestBody func(v any) error
+ // ResponseBody is the schema for the response body. Nil means no validation.
+ ResponseBody func(v any) error
+}
+
+// RouteSchemaMap maps "METHOD /path/pattern" to an EndpointSchema.
+// Patterns use {param} placeholders for path segments.
+type RouteSchemaMap map[string]*EndpointSchema
+
+// matchesPath checks if actualPath matches the pattern.
+// Pattern segments in {...} are treated as wildcards.
+func matchesPath(pattern, actualPath string) bool {
+ patternSegments := strings.Split(strings.Trim(pattern, "/"), "/")
+ actualSegments := strings.Split(strings.Trim(actualPath, "/"), "/")
+ if len(patternSegments) != len(actualSegments) {
+ return false
+ }
+ for i, ps := range patternSegments {
+ if strings.HasPrefix(ps, "{") && strings.HasSuffix(ps, "}") {
+ continue
+ }
+ if ps != actualSegments[i] {
+ return false
+ }
+ }
+ return true
+}
+
+// ValidationMiddleware creates a middleware that validates request/response
+// bodies against the OpenAPI schema. Validation errors are returned as errors.
+//
+// Usage:
+//
+// schemas := RouteSchemaMap{
+// "POST /pets": {RequestBody: ValidatePet, ResponseBody: ValidatePet},
+// "GET /pets/{petId}": {ResponseBody: ValidatePet},
+// }
+// client.Use(ValidationMiddleware(schemas))
+func ValidationMiddleware(schemas RouteSchemaMap) Middleware {
+ return func(ctx context.Context, req *MiddlewareRequest, next func(context.Context, *MiddlewareRequest) (*MiddlewareResponse, error)) (*MiddlewareResponse, error) {
+ method := strings.ToUpper(req.Method)
+ // Strip query string for route matching.
+ path := req.URL
+ if idx := strings.Index(path, "?"); idx >= 0 {
+ path = path[:idx]
+ }
+
+ // Find a matching route schema.
+ routeKey := method + " " + path
+ endpointSchema := schemas[routeKey]
+
+ // Try pattern matching if exact match fails.
+ if endpointSchema == nil {
+ for pattern, schema := range schemas {
+ spaceIdx := strings.Index(pattern, " ")
+ if spaceIdx < 0 {
+ continue
+ }
+ patternMethod := strings.ToUpper(pattern[:spaceIdx])
+ patternPath := pattern[spaceIdx+1:]
+ if patternMethod == method && matchesPath(patternPath, path) {
+ endpointSchema = schema
+ break
+ }
+ }
+ }
+
+ // Validate request body if a schema is defined.
+ if endpointSchema != nil && endpointSchema.RequestBody != nil && len(req.Body) > 0 {
+ var body any
+ if err := json.Unmarshal(req.Body, &body); err == nil {
+ if err := endpointSchema.RequestBody(body); err != nil {
+ return nil, fmt.Errorf("[validation] %s %s request body: %w", method, path, err)
+ }
+ }
+ }
+
+ // Proceed with the request.
+ resp, err := next(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+
+ // Validate response body if a schema is defined.
+ if endpointSchema != nil && endpointSchema.ResponseBody != nil && resp.StatusCode >= 200 && resp.StatusCode < 300 && len(resp.Body) > 0 {
+ contentType := resp.Header.Get("Content-Type")
+ if strings.Contains(contentType, "application/json") {
+ var body any
+ if err := json.Unmarshal(resp.Body, &body); err == nil {
+ if err := endpointSchema.ResponseBody(body); err != nil {
+ return nil, fmt.Errorf("[validation] %s %s response %d: %w", method, path, resp.StatusCode, err)
+ }
+ }
+ }
+ }
+
+ return resp, nil
+ }
+}
diff --git a/examples/petstore-rust/Cargo.toml b/examples/petstore-rust/Cargo.toml
index 2311ad1..12f8467 100644
--- a/examples/petstore-rust/Cargo.toml
+++ b/examples/petstore-rust/Cargo.toml
@@ -11,3 +11,5 @@ path = "src/main.rs"
[dependencies]
petstore_example_sdk = { path = "./sdk" }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
+
+[workspace]
diff --git a/examples/petstore-rust/sdk/README.md b/examples/petstore-rust/sdk/README.md
index 434d9d6..1b2271e 100644
--- a/examples/petstore-rust/sdk/README.md
+++ b/examples/petstore-rust/sdk/README.md
@@ -133,4 +133,98 @@ let client = Client::builder()
.build()?;
```
+## Interceptors
+
+Transform the request body before it is serialized, or the response body after it
+is deserialized, by implementing a trait and registering it on the builder:
+
+```rust
+use petstore_example_sdk::{RequestInterceptor, ResponseInterceptor};
+use serde_json::{json, Value};
+
+// Add a field to every outgoing request body.
+struct AddTraceId;
+impl RequestInterceptor for AddTraceId {
+ fn transform(&self, mut body: Value) -> Value {
+ if let Some(obj) = body.as_object_mut() {
+ obj.insert("trace_id".into(), json!("abc-123"));
+ }
+ body
+ }
+}
+
+// Strip a field from every response body.
+struct StripMeta;
+impl ResponseInterceptor for StripMeta {
+ fn transform(&self, mut body: Value) -> Value {
+ if let Some(obj) = body.as_object_mut() {
+ obj.remove("_meta");
+ }
+ body
+ }
+}
+
+let client = Client::builder()
+ .bearer_token("…")
+ .request_interceptor(AddTraceId)
+ .response_interceptor(StripMeta)
+ .build()?;
+```
+
+## Validation
+
+Validate request/response bodies against the OpenAPI schema at runtime. Toggle the
+built-in validator per client, or wire the generated `validation_middleware` into the
+middleware chain with per-route schemas for finer control:
+
+```rust
+// Whole-client validation (uses the generated validators automatically).
+let client = Client::builder()
+ .bearer_token("…")
+ .validation(true)
+ .build()?;
+
+// Or attach validation as a middleware with an explicit route -> schema map.
+use std::collections::HashMap;
+use petstore_example_sdk::validation_middleware::{validation_middleware, EndpointSchema, RouteSchemaMap};
+use petstore_example_sdk::models; // generated validators live here, e.g. validate_pet
+
+let mut schemas: RouteSchemaMap = HashMap::new();
+schemas.insert(
+ "POST /pets".to_string(),
+ EndpointSchema {
+ request_body: Some(models::validate_pet),
+ response_body: None,
+ },
+);
+let mut client = Client::builder().bearer_token("…").build()?;
+client.use_middleware(validation_middleware(schemas));
+```
+
+## Telemetry & dependency injection
+
+Observe the request lifecycle by implementing `TelemetryHooks`, or group injectable
+dependencies (HTTP client, cache, rate limiter, logger, telemetry) into a
+`ServiceContainer` and apply them in one call:
+
+```rust
+use std::time::Duration;
+use petstore_example_sdk::{MetricsCollector, ServiceContainer};
+
+let metrics = MetricsCollector::default();
+
+let sc = ServiceContainer::new()
+ .cache_ttl(Duration::from_secs(60))
+ .logger(petstore_example_sdk::ConsoleLogger)
+ .telemetry(metrics.clone());
+
+let client = Client::builder()
+ .bearer_token("…")
+ .service_container(sc)
+ .build()?;
+
+// `metrics.get_metrics().await` now reflects every request, retry, and error.
+println!("{:?}", metrics.get_metrics().await);
+```
+
_Do not edit generated files directly._
diff --git a/examples/petstore-rust/sdk/specforge-version.json b/examples/petstore-rust/sdk/specforge-version.json
new file mode 100644
index 0000000..6e5a49d
--- /dev/null
+++ b/examples/petstore-rust/sdk/specforge-version.json
@@ -0,0 +1 @@
+{"specforge_version":"0.8.0","ir_version":"1.0","spec_version":"1.0.0","generated_at":"2026-07-30T06:04:16Z"}
\ No newline at end of file
diff --git a/examples/petstore-rust/sdk/src/client.rs b/examples/petstore-rust/sdk/src/client.rs
index 6529231..bf61774 100644
--- a/examples/petstore-rust/sdk/src/client.rs
+++ b/examples/petstore-rust/sdk/src/client.rs
@@ -9,6 +9,7 @@ use serde::Serialize;
use crate::cache::ResponseCache;
use crate::concurrency::Semaphore;
+ use crate::interceptors::{RequestInterceptor, ResponseInterceptor};
use crate::ratelimit::RateLimiter;
use crate::telemetry::TelemetryHooks;
use crate::dedup::RequestDeduper;
@@ -20,6 +21,77 @@ use crate::cache::ResponseCache;
use crate::retry::{backoff_delay, is_retriable_method, RetryOptions};
use crate::streaming::SseStream;
+ /// Typed service container grouping all DI-able SDK dependencies.
+ /// Create one with `ServiceContainer::new()`, override fields, then pass
+ /// to `ClientBuilder::service_container()` to apply them all at once.
+ #[derive(Clone)]
+ pub struct ServiceContainer {
+ pub http_client: reqwest::Client,
+ pub cache: Option,
+ pub rate_limiter: Option>,
+ pub logger: Option>,
+ pub telemetry: Option>,
+ }
+
+ impl ServiceContainer {
+ /// Create a new container with sensible defaults (default reqwest client,
+ /// console logger, no cache/rate limiter/telemetry).
+ pub fn new() -> Self {
+ Self {
+ http_client: reqwest::Client::builder()
+ .user_agent("specforge-rust-sdk")
+ .build()
+ .expect("failed to build default HTTP client"),
+ cache: None,
+ rate_limiter: None,
+ logger: None,
+ telemetry: None,
+ }
+ }
+
+ /// Set a custom HTTP client, replacing the default.
+ pub fn http_client(mut self, client: reqwest::Client) -> Self {
+ self.http_client = client;
+ self
+ }
+
+ /// Enable response caching with the given TTL.
+ pub fn cache_ttl(mut self, ttl: std::time::Duration) -> Self {
+ self.cache = Some(ResponseCache::new(ttl));
+ self
+ }
+
+ /// Set a pre-configured response cache.
+ pub fn cache(mut self, cache: ResponseCache) -> Self {
+ self.cache = Some(cache);
+ self
+ }
+
+ /// Set a rate limiter.
+ pub fn rate_limiter(mut self, limiter: impl RateLimiter + 'static) -> Self {
+ self.rate_limiter = Some(Arc::new(limiter));
+ self
+ }
+
+ /// Set a structured logger.
+ pub fn logger(mut self, logger: impl crate::logging::Logger + 'static) -> Self {
+ self.logger = Some(Arc::new(logger));
+ self
+ }
+
+ /// Set telemetry hooks.
+ pub fn telemetry(mut self, hooks: impl TelemetryHooks + 'static) -> Self {
+ self.telemetry = Some(Arc::new(hooks));
+ self
+ }
+ }
+
+ impl Default for ServiceContainer {
+ fn default() -> Self {
+ Self::new()
+ }
+ }
+
/// Credential provider applied on every request.
#[derive(Clone)]
pub enum Auth {
@@ -191,7 +263,7 @@ use crate::cache::ResponseCache;
None
};
let body_bytes = match body {
- Some(b) => Some(serde_json::to_vec(b)?),
+ Some(b) => Some(serde_json::to_vec(&b)?),
None => None,
};
@@ -331,15 +403,15 @@ use crate::cache::ResponseCache;
}
// Apply response transformers.
let mut value: serde_json::Value = serde_json::from_slice(&data)?;
- for t in &self.response_transformers {
- value = t.transform(value);
+ for t in &self.response_transformers {
+ value = t.transform(value);
+ }
- // Apply response interceptors.
- for i in &self.response_interceptors {
- value = i.transform(value);
- }
- }
- Ok(serde_json::from_value(value)?)
+ // Apply response interceptors.
+ for i in &self.response_interceptors {
+ value = i.transform(value);
+ }
+ Ok(serde_json::from_value(value)?)
}
/// Issue a streaming request (no retry). Returns the raw Response; use
@@ -465,10 +537,10 @@ use crate::cache::ResponseCache;
if let Some(l) = &self.logger {
l.warn(&format!("[retry] {} {} error={}, attempt {}/{}", method.as_str(), url, last_err.as_ref().unwrap(), attempt + 2, max + 1));
}
- // Telemetry: retry notification.
- if let Some(t) = &self.telemetry {
- t.on_retry(method, url, attempt + 1, &last_err.as_ref().unwrap());
- }
+ // Telemetry: retry notification.
+ if let Some(t) = &self.telemetry {
+ t.on_retry(method.as_str(), url, attempt + 1, &last_err.as_ref().unwrap());
+ }
}
}
}
@@ -479,14 +551,13 @@ use crate::cache::ResponseCache;
&self,
method: &reqwest::Method,
url: &str,
- #[allow(unused_variables)]
- start: std::time::Instant,
query: &[(String, String)],
body_bytes: Option<&[u8]>,
idem_key: Option<&str>,
if_none_match: Option<&str>,
+ start: std::time::Instant,
) -> Result<(Vec, u16, String)> {
- let _start = std::time::Instant::now();
+ let _start = start;
// Build full URL with query for middleware visibility.
let full_url = if query.is_empty() {
@@ -574,15 +645,16 @@ use crate::cache::ResponseCache;
if mw_res.status == 304 {
return Ok((mw_res.body, mw_res.status, etag));
}
- // Telemetry: request error.
- if let Some(t) = &self.telemetry {
- t.on_request_error(method, url, _start.elapsed(), &format!("HTTP {}", mw_res.status));
- }
- return Err(Error::Http {
+ let err = Error::Http {
status: mw_res.status,
body: String::from_utf8_lossy(&mw_res.body).into_owned(),
url: full_url,
- });
+ };
+ // Telemetry: request error.
+ if let Some(t) = &self.telemetry {
+ t.on_request_error(method.as_str(), url, _start.elapsed().as_millis(), &err);
+ }
+ return Err(err);
}
// Logger: response received.
if let Some(l) = &self.logger {
@@ -591,7 +663,7 @@ use crate::cache::ResponseCache;
// Telemetry: successful request.
if let Some(t) = &self.telemetry {
- t.on_request_end(method, url, _start.elapsed(), mw_res.status);
+ t.on_request_end(method.as_str(), url, _start.elapsed().as_millis(), mw_res.status);
}
Ok((mw_res.body, mw_res.status, etag))
}
@@ -642,6 +714,7 @@ use crate::cache::ResponseCache;
cache: Option,
rate_limiter: Option>,
telemetry: Option>,
+ logger: Option>,
middlewares: Vec,
stream_middlewares: Vec,
/// Seeded from the first API-key security scheme in the spec, if any.
@@ -683,10 +756,11 @@ use crate::cache::ResponseCache;
dedupe: true,
idempotency: true,
validation: false,
- cache: None,
- rate_limiter: None,
- telemetry: None,
- response_transformers: Vec::new(),
+ cache: None,
+ rate_limiter: None,
+ telemetry: None,
+ logger: None,
+ response_transformers: Vec::new(),
request_interceptors: Vec::new(),
response_interceptors: Vec::new(),
middlewares: Vec::new(),
@@ -812,7 +886,27 @@ use crate::cache::ResponseCache;
self.http_client = Some(client);
self
}
-
+
+ /// Apply a [`ServiceContainer`] to configure all DI-able services at once.
+ /// Non-None container values override the builder defaults; individual
+ /// builder methods called afterward take final precedence.
+ pub fn service_container(mut self, sc: ServiceContainer) -> Self {
+ self.http_client = Some(sc.http_client);
+ if let Some(cache) = sc.cache {
+ self.cache = Some(cache);
+ }
+ if let Some(limiter) = sc.rate_limiter {
+ self.rate_limiter = Some(limiter);
+ }
+ if let Some(logger) = sc.logger {
+ self.logger = Some(logger);
+ }
+ if let Some(telemetry) = sc.telemetry {
+ self.telemetry = Some(telemetry);
+ }
+ self
+ }
+
pub fn build(self) -> Result {
let http = match self.http_client {
Some(c) => c,
diff --git a/examples/petstore-rust/sdk/src/lib.rs b/examples/petstore-rust/sdk/src/lib.rs
index 673e828..2899059 100644
--- a/examples/petstore-rust/sdk/src/lib.rs
+++ b/examples/petstore-rust/sdk/src/lib.rs
@@ -9,6 +9,7 @@ pub mod concurrency;
pub mod dedup;
pub mod error;
pub mod idempotency;
+pub mod interceptors;
pub mod logging;
pub mod middleware;
pub mod models;
@@ -18,8 +19,9 @@ pub mod retry;
pub mod streaming;
pub mod telemetry;
pub mod validate;
+pub mod validation_middleware;
-pub use client::{Auth, Client, ClientBuilder, ResponseTransformer};
+pub use client::{Auth, Client, ClientBuilder, ResponseTransformer, ServiceContainer};
pub use interceptors::{RequestInterceptor, ResponseInterceptor};
pub use concurrency::Semaphore;
pub use dedup::RequestDeduper;
@@ -32,3 +34,4 @@ pub use ratelimit::{RateLimiter, SlidingWindow, TokenBucket};
pub use retry::RetryOptions;
pub use telemetry::{MetricsCollector, TelemetryHooks};
pub use streaming::{ServerSentEvent, SseStream};
+pub use validation_middleware::{validation_middleware, EndpointSchema, RouteSchemaMap};
diff --git a/examples/petstore-rust/sdk/src/validation_middleware.rs b/examples/petstore-rust/sdk/src/validation_middleware.rs
new file mode 100644
index 0000000..3585849
--- /dev/null
+++ b/examples/petstore-rust/sdk/src/validation_middleware.rs
@@ -0,0 +1,146 @@
+// Code generated by specforge. DO NOT EDIT.
+
+//! Automatic request/response validation middleware that intercepts all
+//! requests and validates bodies against the OpenAPI schema.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use serde_json::Value;
+
+use crate::error::{Error, Result};
+use crate::middleware::{Middleware, MiddlewareRequest, MiddlewareResponse, NextFn};
+use crate::validate::ValidationError;
+
+/// Schema descriptor for an endpoint's request and/or response body validator.
+pub struct EndpointSchema {
+ /// Validator function for the request body. None means no validation.
+ pub request_body: Option std::result::Result<(), Vec>>,
+ /// Validator function for the response body. None means no validation.
+ pub response_body: Option std::result::Result<(), Vec>>,
+}
+
+/// Route schema map keyed by "METHOD /path/pattern".
+/// Patterns use `{param}` placeholders for path segments.
+pub type RouteSchemaMap = HashMap;
+
+/// Check if `actual_path` matches the `pattern` path.
+/// Pattern segments wrapped in `{...}` are treated as wildcards.
+fn matches_path(pattern: &str, actual_path: &str) -> bool {
+ let pattern_segments: Vec<&str> = pattern.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect();
+ let actual_segments: Vec<&str> = actual_path.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect();
+ if pattern_segments.len() != actual_segments.len() {
+ return false;
+ }
+ for (ps, as_) in pattern_segments.iter().zip(actual_segments.iter()) {
+ if ps.starts_with('{') && ps.ends_with('}') {
+ continue; // wildcard
+ }
+ if ps != as_ {
+ return false;
+ }
+ }
+ true
+}
+
+/// Create a validation middleware that intercepts all requests and validates
+/// request/response bodies against the OpenAPI schema.
+///
+/// # Usage
+///
+/// ```ignore
+/// use std::collections::HashMap;
+/// use sdk::validation_middleware::{validation_middleware, EndpointSchema, RouteSchemaMap};
+/// use sdk::validate::validate_pet;
+///
+/// let mut schemas: RouteSchemaMap = HashMap::new();
+/// schemas.insert(
+/// "POST /pets".to_string(),
+/// EndpointSchema {
+/// request_body: Some(validate_pet),
+/// response_body: Some(validate_pet),
+/// },
+/// );
+/// schemas.insert(
+/// "GET /pets/{petId}".to_string(),
+/// EndpointSchema {
+/// request_body: None,
+/// response_body: Some(validate_pet),
+/// },
+/// );
+///
+/// client.use_middleware(validation_middleware(schemas));
+/// ```
+pub fn validation_middleware(schemas: RouteSchemaMap) -> Middleware {
+ let schemas = Arc::new(schemas);
+ Arc::new(move |req: MiddlewareRequest, next: NextFn| {
+ let schemas = Arc::clone(&schemas);
+ Box::pin(async move {
+ let method = req.method.as_str().to_uppercase();
+ let path = req.url.split('?').next().unwrap_or("").to_string();
+
+ // Find a matching route schema.
+ let route_key = format!("{} {}", method, path);
+ let endpoint_schema = schemas.get(&route_key).or_else(|| {
+ schemas.iter().find_map(|(pattern, schema)| {
+ let space_idx = pattern.find(' ')?;
+ let pattern_method = pattern[..space_idx].to_uppercase();
+ let pattern_path = &pattern[space_idx + 1..];
+ if pattern_method == method && matches_path(pattern_path, &path) {
+ Some(schema)
+ } else {
+ None
+ }
+ })
+ });
+
+ // Validate request body if a schema is defined.
+ if let Some(schema) = endpoint_schema {
+ if let Some(validator) = schema.request_body {
+ if let Some(body_bytes) = &req.body {
+ if !body_bytes.is_empty() {
+ if let Ok(body) = serde_json::from_slice::(body_bytes) {
+ if let Err(errors) = validator(&body) {
+ let msg: Vec = errors.iter().map(|e| format!("{}: {}", e.path, e.message)).collect();
+ return Err(Error::Message(format!(
+ "[validation] {} {} request body: {}",
+ method, path, msg.join("; ")
+ )));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Proceed with the request.
+ let resp = next(req).await?;
+
+ // Validate response body if a schema is defined.
+ if let Some(schema) = endpoint_schema {
+ if let Some(validator) = schema.response_body {
+ if resp.status >= 200 && resp.status < 300 && !resp.body.is_empty() {
+ let is_json = resp.headers
+ .get("content-type")
+ .and_then(|v| v.to_str().ok())
+ .map(|ct| ct.contains("application/json"))
+ .unwrap_or(false);
+ if is_json {
+ if let Ok(body) = serde_json::from_slice::(&resp.body) {
+ if let Err(errors) = validator(&body) {
+ let msg: Vec = errors.iter().map(|e| format!("{}: {}", e.path, e.message)).collect();
+ return Err(Error::Message(format!(
+ "[validation] {} {} response {}: {}",
+ method, path, resp.status, msg.join("; ")
+ )));
+ }
+ }
+ }
+ }
+ }
+ }
+
+ Ok(resp)
+ })
+ })
+}
diff --git a/examples/petstore-ts/sdk/specforge-version.json b/examples/petstore-ts/sdk/specforge-version.json
new file mode 100644
index 0000000..1b2d9ec
--- /dev/null
+++ b/examples/petstore-ts/sdk/specforge-version.json
@@ -0,0 +1 @@
+{"specforge_version":"0.8.0","ir_version":"1.0","spec_version":"1.0.0","generated_at":"2026-07-30T06:02:33Z"}
\ No newline at end of file
diff --git a/examples/petstore-ts/sdk/src/client.ts b/examples/petstore-ts/sdk/src/client.ts
index 3919b30..54d3acf 100644
--- a/examples/petstore-ts/sdk/src/client.ts
+++ b/examples/petstore-ts/sdk/src/client.ts
@@ -12,7 +12,10 @@ import { Middleware, MiddlewareRequest, composeMiddleware } from "./middleware";
import { validateRequest, validateResponse, type SchemaType, type ValidationError } from "./validate";
import { ResponseCache } from "./cache";
import { type Logger, ConsoleLogger } from "./logging";
+import { type TelemetryHooks } from "./telemetry";
import { type RequestInterceptor, type ResponseInterceptor } from "./interceptors";
+import { type ServiceContainer } from "./service_container";
+import { createValidationMiddleware, type RouteSchemaMap } from "./validation-middleware";
/**
* Transform response data before it reaches the application. Transformers are
@@ -55,10 +58,18 @@ export interface ApiClientOptions {
responseTransformers?: ResponseTransformer[];
/** Structured logger for SDK lifecycle events (requests, retries, cache). */
logger?: Logger;
+ /** Telemetry hooks for request lifecycle observability. Implement any subset. */
+ telemetry?: TelemetryHooks;
/** Request interceptors applied before each request body is serialized. */
requestInterceptors?: RequestInterceptor[];
/** Response interceptors applied after each response body is deserialized. */
responseInterceptors?: ResponseInterceptor[];
+ /**
+ * Typed service container grouping injectable dependencies (fetch, cache,
+ * rate limiter, logger, telemetry). Individual option fields take precedence
+ * over container values.
+ */
+ container?: ServiceContainer;
}
/**
@@ -90,17 +101,21 @@ export class ApiClient {
private readonly responseTransformers: ResponseTransformer[];
/** Structured logger for SDK lifecycle events. */
private readonly logger: Logger;
+ /** Optional telemetry hooks for request lifecycle observability. */
+ private readonly telemetry?: TelemetryHooks;
/** Request interceptors applied before each request body is serialized. */
private readonly requestInterceptors: RequestInterceptor[];
/** Response interceptors applied after each response body is deserialized. */
private readonly responseInterceptors: ResponseInterceptor[];
constructor(opts: ApiClientOptions = {}) {
+ const c = opts.container;
this.baseUrl = (opts.baseUrl ?? "http://petstore.swagger.io/v1").replace(/\/$/, "");
this.auth = opts.auth ?? anonymousAuth();
this.timeoutMs = opts.timeoutMs ?? 30_000;
this.retry = { ...DEFAULT_RETRY_OPTIONS, ...opts.retry };
- this.fetchImpl = opts.fetch ?? fetch;
+ // Individual options take precedence over container values.
+ this.fetchImpl = opts.fetch ?? c?.httpClient ?? fetch;
this.defaultHeaders = { "content-type": "application/json", ...opts.defaultHeaders };
if (!("accept" in this.defaultHeaders)) this.defaultHeaders["accept"] = "application/json";
this.semaphore = opts.maxConcurrent ? createSemaphore(opts.maxConcurrent) : UNLIMITED;
@@ -108,9 +123,10 @@ export class ApiClient {
this.useDedupe = opts.dedupe ?? true;
this.useIdempotency = opts.idempotency ?? true;
this.validate = opts.validate ?? false;
- this.cache = opts.cache ?? (opts.cacheTtlMs !== undefined ? new ResponseCache(opts.cacheTtlMs) : undefined);
+ this.cache = opts.cache ?? c?.cache ?? (opts.cacheTtlMs !== undefined ? new ResponseCache(opts.cacheTtlMs) : undefined);
this.responseTransformers = opts.responseTransformers ?? [];
- this.logger = opts.logger ?? new ConsoleLogger();
+ this.logger = opts.logger ?? c?.logger ?? new ConsoleLogger();
+ this.telemetry = opts.telemetry ?? c?.telemetry;
this.requestInterceptors = opts.requestInterceptors ?? [];
this.responseInterceptors = opts.responseInterceptors ?? [];
if (opts.middleware) for (const m of opts.middleware) this.middlewares.push(m);
@@ -139,8 +155,10 @@ export class ApiClient {
const cachedEntry = isGet && this.cache ? this.cache.get(url) : undefined;
if (cachedEntry) {
this.logger.debug(`[cache] HIT ${method} ${path}`);
+ this.telemetry?.onCacheHit?.(method, path);
} else if (isGet && this.cache) {
this.logger.debug(`[cache] MISS ${method} ${path}`);
+ this.telemetry?.onCacheMiss?.(method, path);
}
const mergedHeaders: Record | undefined =
cachedEntry
@@ -151,7 +169,7 @@ export class ApiClient {
const dispatch = async (): Promise => {
const release = await this.semaphore.acquire();
try {
- return await this.dispatchWithRetry(method, url, { ...requestOptions, headers: mergedHeaders });
+ return await this.dispatchWithRetry(method, url, path, { ...requestOptions, headers: mergedHeaders });
} finally {
release();
}
@@ -197,6 +215,7 @@ export class ApiClient {
private async dispatchWithRetry(
method: string,
url: string,
+ path: string,
requestOptions: RequestOptions | undefined,
): Promise {
const perCallRetry = requestOptions?.retry;
@@ -224,6 +243,7 @@ export class ApiClient {
callerSignal?.addEventListener("abort", onCallerAbort!, { once: true });
this.logger.info(`[request] ${method} ${url}${attempt > 0 ? ` (attempt ${attempt + 1})` : ""}`);
+ if (attempt === 0) this.telemetry?.onRequestStart?.(method, path);
// Build the request descriptor for middleware.
const mwReq: MiddlewareRequest = {
@@ -251,6 +271,7 @@ export class ApiClient {
if (response.ok) {
this.logger.info(`[response] ${method} ${url} -> ${response.status}`);
+ this.telemetry?.onRequestEnd?.(method, path, Date.now() - startedAt, response.status);
return response;
}
@@ -260,27 +281,37 @@ export class ApiClient {
this.retry.retryOnStatuses.includes(response.status);
if (shouldRetry) {
this.logger.warn(`[retry] ${method} ${url} status=${response.status}, attempt ${attempt + 2}/${maxRetries + 1}`);
+ this.telemetry?.onRetry?.(method, path, attempt + 1, response.status);
await sleep(backoffDelay(attempt, this.retry));
continue;
}
const body = await parseBody(response);
- throw httpError(response.status, body, url, response.statusText || `HTTP ${response.status}`);
+ const httpErr = httpError(response.status, body, url, response.statusText || `HTTP ${response.status}`);
+ this.telemetry?.onRequestError?.(method, path, Date.now() - startedAt, httpErr);
+ throw httpErr;
} catch (e) {
if (timer) clearTimeout(timer);
callerSignal?.removeEventListener("abort", onCallerAbort!);
+ // ApiError (e.g. the httpErr built above) was already reported via
+ // telemetry before being thrown — re-throw without double-counting.
if (isApiError(e)) throw e;
// Distinguish caller-cancelled from our-timeout from network.
if (callerSignal?.aborted) throw networkError(e, "request aborted by caller");
if (e instanceof DOMException && e.name === "AbortError") {
- throw timeoutError(Date.now() - startedAt);
+ const err = timeoutError(Date.now() - startedAt);
+ this.telemetry?.onRequestError?.(method, path, Date.now() - startedAt, err);
+ throw err;
}
if (attempt < maxRetries && retryable) {
this.logger.warn(`[retry] ${method} ${url} error=${(e as Error)?.message}, attempt ${attempt + 2}/${maxRetries + 1}`);
+ this.telemetry?.onRetry?.(method, path, attempt + 1, e);
await sleep(backoffDelay(attempt, this.retry));
continue;
}
- throw networkError(e, (e as Error)?.message ?? "network error");
+ const err = networkError(e, (e as Error)?.message ?? "network error");
+ this.telemetry?.onRequestError?.(method, path, Date.now() - startedAt, err);
+ throw err;
}
}
throw configurationError("exhausted retries without a response");
@@ -377,4 +408,27 @@ async function parseBody(response: Response): Promise {
}
}
+/**
+ * Create an options object with automatic validation middleware pre-registered.
+ * Pass the schemas map to enable request/response validation for all requests
+ * matching the given route patterns.
+ *
+ * Usage:
+ * ```ts
+ * import { createClient, withValidation, PetSchema } from "./sdk";
+ *
+ * const schemas = {
+ * "POST /pets": { requestBody: PetSchema, responseBody: PetSchema },
+ * "GET /pets/{petId}": { responseBody: PetSchema },
+ * };
+ *
+ * const client = createClient(withValidation(schemas));
+ * ```
+ */
+export function withValidation(schemas: RouteSchemaMap): ApiClientOptions {
+ return {
+ middleware: [createValidationMiddleware(schemas)],
+ };
+}
+
export type { ApiError };
diff --git a/examples/petstore-ts/sdk/src/index.ts b/examples/petstore-ts/sdk/src/index.ts
index e9d2cd5..34e3177 100644
--- a/examples/petstore-ts/sdk/src/index.ts
+++ b/examples/petstore-ts/sdk/src/index.ts
@@ -22,9 +22,11 @@ export * from "./auth";
export * from "./retry";
export * from "./paginate";
export * from "./validate";
+export * from "./validation-middleware";
export * from "./ratelimit";
export * from "./telemetry";
export * from "./logging";
+export * from "./service_container";
export * from "./models/Error";
export * from "./models/Pet";
diff --git a/examples/petstore-ts/sdk/src/ratelimit.ts b/examples/petstore-ts/sdk/src/ratelimit.ts
new file mode 100644
index 0000000..ae160e5
--- /dev/null
+++ b/examples/petstore-ts/sdk/src/ratelimit.ts
@@ -0,0 +1,83 @@
+/* eslint-disable */
+// Generated by specforge. DO NOT EDIT.
+
+/**
+ * Rate limiter interface. The client calls `acquire()` before each request;
+ * the returned promise resolves when the request is allowed to proceed.
+ */
+export interface RateLimiter {
+ /** Block until the request is allowed. Reject to abort. */
+ acquire(): Promise;
+}
+
+/**
+ * Token bucket rate limiter. Tokens refill at a constant rate up to a maximum.
+ * Each request consumes one token; when empty, `acquire()` waits for a refill.
+ */
+export class TokenBucket implements RateLimiter {
+ private tokens: number;
+ private readonly maxTokens: number;
+ private readonly refillRate: number; // tokens per second
+ private lastRefill: number;
+
+ constructor(maxTokens: number, refillRate: number) {
+ this.maxTokens = maxTokens;
+ this.tokens = maxTokens;
+ this.refillRate = refillRate;
+ this.lastRefill = Date.now();
+ }
+
+ async acquire(): Promise {
+ this.refill();
+ while (this.tokens < 1) {
+ // Wait for the next refill.
+ const waitMs = Math.ceil((1 - this.tokens) / this.refillRate * 1000);
+ await new Promise((resolve) => setTimeout(resolve, Math.max(waitMs, 10)));
+ this.refill();
+ }
+ this.tokens -= 1;
+ }
+
+ private refill(): void {
+ const now = Date.now();
+ const elapsed = (now - this.lastRefill) / 1000;
+ this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
+ this.lastRefill = now;
+ }
+}
+
+/**
+ * Sliding window rate limiter. Allows at most `maxRequests` within any rolling
+ * `windowMs`. When the limit is reached, `acquire()` waits until the oldest
+ * request in the window expires.
+ */
+export class SlidingWindow implements RateLimiter {
+ private readonly requests: number[] = [];
+ private readonly maxRequests: number;
+ private readonly windowMs: number;
+
+ constructor(maxRequests: number, windowMs: number) {
+ this.maxRequests = maxRequests;
+ this.windowMs = windowMs;
+ }
+
+ async acquire(): Promise {
+ this.evict();
+ if (this.requests.length >= this.maxRequests) {
+ const oldest = this.requests[0]!;
+ const waitMs = this.windowMs - (Date.now() - oldest);
+ if (waitMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
+ }
+ this.evict();
+ }
+ this.requests.push(Date.now());
+ }
+
+ private evict(): void {
+ const cutoff = Date.now() - this.windowMs;
+ while (this.requests.length > 0 && this.requests[0]! <= cutoff) {
+ this.requests.shift();
+ }
+ }
+}
diff --git a/examples/petstore-ts/sdk/src/service_container.ts b/examples/petstore-ts/sdk/src/service_container.ts
new file mode 100644
index 0000000..3be830d
--- /dev/null
+++ b/examples/petstore-ts/sdk/src/service_container.ts
@@ -0,0 +1,46 @@
+/* eslint-disable */
+// Generated by specforge. DO NOT EDIT.
+
+import type { ResponseCache } from "./cache";
+import type { RateLimiter } from "./ratelimit";
+import type { Logger } from "./logging";
+import type { TelemetryHooks } from "./telemetry";
+
+/**
+ * Typed service container grouping all injectable SDK dependencies.
+ * Use `createContainer()` to build one; pass it to `ApiClientOptions.container`
+ * to apply all services at once.
+ *
+ * Every field is optional: unspecified services use the SDK defaults
+ * (global fetch, no cache, no rate limiter, console logger, no telemetry).
+ *
+ * @example
+ * ```ts
+ * const container = createContainer({
+ * httpClient: myCustomFetch,
+ * logger: new ConsoleLogger(),
+ * rateLimiter: new TokenBucket(10, 5),
+ * });
+ * const client = new ApiClient({ container });
+ * ```
+ */
+export interface ServiceContainer {
+ /** Custom fetch implementation (e.g. for testing, undici, or cloud workers). */
+ httpClient?: typeof fetch;
+ /** Pre-configured response cache for GET requests with ETag support. */
+ cache?: ResponseCache;
+ /** Rate limiter applied before each request. */
+ rateLimiter?: RateLimiter;
+ /** Structured logger for SDK lifecycle events. */
+ logger?: Logger;
+ /** Telemetry hooks for request lifecycle observability. */
+ telemetry?: TelemetryHooks;
+}
+
+/**
+ * Create a typed service container. All fields default to `undefined`
+ * (the SDK applies its own defaults for unspecified services).
+ */
+export function createContainer(opts: ServiceContainer = {}): ServiceContainer {
+ return { ...opts };
+}
diff --git a/examples/petstore-ts/sdk/src/telemetry.ts b/examples/petstore-ts/sdk/src/telemetry.ts
new file mode 100644
index 0000000..6a34618
--- /dev/null
+++ b/examples/petstore-ts/sdk/src/telemetry.ts
@@ -0,0 +1,69 @@
+/* eslint-disable */
+// Generated by specforge. DO NOT EDIT.
+
+/**
+ * Telemetry hooks for observing the SDK request lifecycle.
+ * Implement any subset; unused hooks are simply not called.
+ */
+export interface TelemetryHooks {
+ /** Called just before the request is dispatched. */
+ onRequestStart?(method: string, path: string): void;
+ /** Called after a successful response (2xx) with the elapsed duration. */
+ onRequestEnd?(method: string, path: string, durationMs: number, status: number): void;
+ /** Called when a request fails (network, timeout, non-retriable HTTP error). */
+ onRequestError?(method: string, path: string, durationMs: number, error: unknown): void;
+ /** Called before a retry attempt (after backoff sleep). */
+ onRetry?(method: string, path: string, attempt: number, error: unknown): void;
+ /** Called when a GET request is served from the response cache. */
+ onCacheHit?(method: string, path: string): void;
+ /** Called when a GET request misses the cache and goes to the network. */
+ onCacheMiss?(method: string, path: string): void;
+}
+
+/**
+ * Built-in metrics collector that tracks request counts, errors, durations,
+ * and retries. Use `getMetrics()` to obtain a snapshot.
+ */
+export class MetricsCollector implements TelemetryHooks {
+ private requestCount = 0;
+ private errorCount = 0;
+ private totalDurationMs = 0;
+ private retryCount = 0;
+
+ onRequestStart(_method: string, _path: string): void {
+ this.requestCount++;
+ }
+
+ onRequestEnd(_method: string, _path: string, durationMs: number, status: number): void {
+ this.totalDurationMs += durationMs;
+ if (status >= 400) this.errorCount++;
+ }
+
+ onRequestError(_method: string, _path: string, durationMs: number, _error: unknown): void {
+ this.errorCount++;
+ this.totalDurationMs += durationMs;
+ }
+
+ onRetry(_method: string, _path: string, _attempt: number, _error: unknown): void {
+ this.retryCount++;
+ }
+
+ /** Return a snapshot of the collected metrics. */
+ getMetrics(): Metrics {
+ return {
+ requestCount: this.requestCount,
+ errorCount: this.errorCount,
+ totalDurationMs: this.totalDurationMs,
+ avgDurationMs: this.requestCount > 0 ? this.totalDurationMs / this.requestCount : 0,
+ retryCount: this.retryCount,
+ };
+ }
+}
+
+export interface Metrics {
+ requestCount: number;
+ errorCount: number;
+ totalDurationMs: number;
+ avgDurationMs: number;
+ retryCount: number;
+}
diff --git a/examples/petstore-ts/sdk/src/validate.ts b/examples/petstore-ts/sdk/src/validate.ts
index e139960..403d86c 100644
--- a/examples/petstore-ts/sdk/src/validate.ts
+++ b/examples/petstore-ts/sdk/src/validate.ts
@@ -38,7 +38,9 @@ export const ErrorSchema: SchemaObject = {
};
export function validateError(value: unknown, path = ""): ValidationError[] {
- return validateValue(value, ErrorSchema, path);
+ const errors: ValidationError[] = [];
+ validateValue(value, ErrorSchema, path, errors);
+ return errors;
}
export const PetSchema: SchemaObject = {
@@ -51,7 +53,9 @@ export const PetSchema: SchemaObject = {
};
export function validatePet(value: unknown, path = ""): ValidationError[] {
- return validateValue(value, PetSchema, path);
+ const errors: ValidationError[] = [];
+ validateValue(value, PetSchema, path, errors);
+ return errors;
}
export const PetsSchema: SchemaObject = {
@@ -61,7 +65,9 @@ export const PetsSchema: SchemaObject = {
};
export function validatePets(value: unknown, path = ""): ValidationError[] {
- return validateValue(value, PetsSchema, path);
+ const errors: ValidationError[] = [];
+ validateValue(value, PetsSchema, path, errors);
+ return errors;
}
diff --git a/examples/petstore-ts/sdk/src/validation-middleware.ts b/examples/petstore-ts/sdk/src/validation-middleware.ts
new file mode 100644
index 0000000..4cfff98
--- /dev/null
+++ b/examples/petstore-ts/sdk/src/validation-middleware.ts
@@ -0,0 +1,145 @@
+/* eslint-disable */
+// Generated by specforge. DO NOT EDIT.
+
+import { Middleware, MiddlewareRequest } from "./middleware";
+import { SchemaType, ValidationError, validateValue } from "./validate";
+
+/**
+ * Schema descriptor for an endpoint's request and/or response body.
+ */
+export interface EndpointSchema {
+ /** Schema for the request body. Undefined means no request body validation. */
+ requestBody?: SchemaType;
+ /** Schema for the response body. Undefined means no response body validation. */
+ responseBody?: SchemaType;
+}
+
+/**
+ * A route schema map keyed by "METHOD /path/pattern".
+ * Patterns use `{param}` placeholders for path segments.
+ */
+export interface RouteSchemaMap {
+ [route: string]: EndpointSchema;
+}
+
+/**
+ * Validate that `actualPath` matches the `pattern` path.
+ * Pattern segments wrapped in `{...}` are treated as wildcards.
+ * Returns true when every segment matches (exact or wildcard).
+ */
+function matchesPath(pattern: string, actualPath: string): boolean {
+ const patternSegments = pattern.split("/").filter(Boolean);
+ const actualSegments = actualPath.split("/").filter(Boolean);
+ if (patternSegments.length !== actualSegments.length) return false;
+ for (let i = 0; i < patternSegments.length; i++) {
+ const ps = patternSegments[i]!;
+ if (ps.startsWith("{") && ps.endsWith("}")) continue; // wildcard
+ if (ps !== actualSegments[i]) return false;
+ }
+ return true;
+}
+
+/**
+ * Create a validation middleware that intercepts all requests and validates
+ * request/response bodies against the OpenAPI schema.
+ *
+ * Usage:
+ * ```ts
+ * import { createValidationMiddleware, RouteSchemaMap } from "./validation-middleware";
+ *
+ * const schemas: RouteSchemaMap = {
+ * "POST /pets": { requestBody: PetSchema, responseBody: PetSchema },
+ * "GET /pets/{petId}": { responseBody: PetSchema },
+ * };
+ *
+ * client.use(createValidationMiddleware(schemas));
+ * ```
+ *
+ * The middleware validates request bodies before dispatch and response bodies
+ * after a successful response. Validation errors are thrown as Error objects
+ * with a `validationErrors` property containing the details.
+ */
+export function createValidationMiddleware(schemas: RouteSchemaMap): Middleware {
+ return async (req: MiddlewareRequest, next: (req: MiddlewareRequest) => Promise): Promise => {
+ const method = req.method.toUpperCase();
+ // Strip query string for route matching.
+ const path = req.url.split("?")[0] || "";
+
+ // Find a matching route schema.
+ const routeKey = `${method} ${path}`;
+ let endpointSchema = schemas[routeKey];
+
+ // Try pattern matching if exact match fails.
+ if (!endpointSchema) {
+ for (const [pattern, schema] of Object.entries(schemas)) {
+ const spaceIdx = pattern.indexOf(" ");
+ if (spaceIdx < 0) continue;
+ const patternMethod = pattern.substring(0, spaceIdx).toUpperCase();
+ const patternPath = pattern.substring(spaceIdx + 1);
+ if (patternMethod === method && matchesPath(patternPath, path)) {
+ endpointSchema = schema;
+ break;
+ }
+ }
+ }
+
+ // Validate request body if a schema is defined.
+ if (endpointSchema?.requestBody && req.body !== undefined && req.body !== null) {
+ let body: unknown;
+ if (typeof req.body === "string") {
+ try { body = JSON.parse(req.body); } catch { body = req.body; }
+ } else if (
+ typeof ReadableStream !== "undefined" && req.body instanceof ReadableStream ||
+ typeof Blob !== "undefined" && req.body instanceof Blob ||
+ typeof ArrayBuffer !== "undefined" && req.body instanceof ArrayBuffer ||
+ typeof FormData !== "undefined" && req.body instanceof FormData
+ ) {
+ // Cannot validate non-JSON body types; skip.
+ } else {
+ body = req.body;
+ }
+ if (body !== undefined && body !== null && typeof body !== "string") {
+ const errors: ValidationError[] = [];
+ validateValue(body, endpointSchema.requestBody, "body", errors);
+ if (errors.length > 0) {
+ const err = new Error(
+ `[validation] ${method} ${path} request body: ${errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`,
+ );
+ (err as any).validationErrors = errors;
+ throw err;
+ }
+ }
+ }
+
+ // Proceed with the request.
+ const response = await next(req);
+
+ // Validate response body if a schema is defined and the response is JSON.
+ if (endpointSchema?.responseBody && response.ok) {
+ const contentType = response.headers.get("content-type") || "";
+ if (contentType.includes("application/json")) {
+ const cloned = response.clone();
+ try {
+ const text = await cloned.text();
+ if (text) {
+ const body = JSON.parse(text);
+ const errors: ValidationError[] = [];
+ validateValue(body, endpointSchema.responseBody, "body", errors);
+ if (errors.length > 0) {
+ const err = new Error(
+ `[validation] ${method} ${path} response ${response.status}: ${errors.map((e) => `${e.path}: ${e.message}`).join("; ")}`,
+ );
+ (err as any).validationErrors = errors;
+ throw err;
+ }
+ }
+ } catch (e) {
+ if ((e as any).validationErrors) throw e;
+ // Non-JSON or parse error -- skip response validation.
+ }
+ }
+ }
+
+ return response;
+ };
+}
From ca8d949ab326c879594ac8d8863185d98f081c21 Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 30 Jul 2026 08:07:27 +0000
Subject: [PATCH 02/19] core: Refactor diff markdown + fix shadowed binary
patterns
- diff.rs: expand golfed fmt_md_msg into readable prefixed-if chain
(fixes 13 clippy else-if warnings)
- testgen.rs: Scalar::Binary was shadowed by a Boolean|Base64|Binary arm,
so binary fields generated "true"/boolean asserts instead of
Uint8Array. Split Binary into its own arm (3 sites).
- spec.rs: collapse match to matches! macro
- resolve.rs: take &T instead of &Box
- rust emitter: remove dead rust_method_doc_comment
- apply remaining clippy autofixes across crates
---
crates/specforge-cli/src/main.rs | 60 +++++++++----------
crates/specforge-cli/tests/edge_cases.rs | 2 +-
crates/specforge-cli/tests/sdk_integration.rs | 6 +-
crates/specforge-core/src/changelog.rs | 4 +-
crates/specforge-core/src/diff.rs | 57 +++++++++++++++++-
crates/specforge-core/src/profiler.rs | 4 +-
crates/specforge-core/src/resolve.rs | 4 +-
crates/specforge-core/src/spec.rs | 8 +--
crates/specforge-core/src/testgen.rs | 6 +-
crates/specforge-go/src/lib.rs | 12 ++--
crates/specforge-rust/src/lib.rs | 42 +------------
11 files changed, 108 insertions(+), 97 deletions(-)
diff --git a/crates/specforge-cli/src/main.rs b/crates/specforge-cli/src/main.rs
index f8cdcd8..c736299 100644
--- a/crates/specforge-cli/src/main.rs
+++ b/crates/specforge-cli/src/main.rs
@@ -12,7 +12,7 @@ use serde_json::Value as JsonValue;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
-use specforge_core::{diff, export_spec, generate_changelog, generate_demo_spec, lint, lint_config, merge_specs, parse_file, profile_api, resolve, resolve_spec_path, scan_versions, apply_versioning, ChangelogFormat, ChangelogOptions, DiffSeverity, ExportOptions, LintConfig, MarketplaceIndex, PluginIndex, ProfileOptions, RuleSeverity, Severity, SpecforgeConfig, VersioningConfig, VersionStrategy};
+use specforge_core::{diff, generate_changelog, lint, lint_config, merge_specs, parse_file, profile_api, resolve, resolve_spec_path, scan_versions, apply_versioning, ChangelogFormat, ChangelogOptions, DiffSeverity, LintConfig, MarketplaceIndex, PluginIndex, ProfileOptions, RuleSeverity, Severity, SpecforgeConfig, VersioningConfig, VersionStrategy};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum LogLevel {
@@ -870,7 +870,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -884,7 +884,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -898,7 +898,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -906,7 +906,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -914,7 +914,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -922,7 +922,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -930,7 +930,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -941,7 +941,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -949,7 +949,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -957,7 +957,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -975,7 +975,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -990,7 +990,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -998,7 +998,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1006,7 +1006,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1014,7 +1014,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1029,7 +1029,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1037,7 +1037,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1045,7 +1045,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1053,7 +1053,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1061,7 +1061,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1069,7 +1069,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1077,7 +1077,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1085,7 +1085,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1093,7 +1093,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1101,7 +1101,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1781,7 +1781,7 @@ fn run_merge(cli: &MergeArgs) -> Result<()> {
info!("reading spec: {}", path.display());
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read spec at {}", path.display()))?;
- let val: serde_json::Value = if path.extension().map_or(false, |e| e == "json") {
+ let val: serde_json::Value = if path.extension().is_some_and(|e| e == "json") {
serde_json::from_str(&text)
.with_context(|| format!("failed to parse {} as JSON", path.display()))?
} else {
@@ -2521,11 +2521,10 @@ fn run_market_list(index: &MarketplaceIndex, cli: &MarketListArgs) -> Result<()>
.unwrap_or(20)
.max(20);
eprintln!(
- " {:10} {:>5} {}",
+ " {:10} {:>5} DESCRIPTION",
"NAME",
"DOWNLOADS",
"RATING",
- "DESCRIPTION",
width = name_w,
);
eprintln!(
@@ -2836,12 +2835,11 @@ fn run_plugin_list(index: &PluginIndex, cli: &PluginListArgs) -> Result<()> {
.unwrap_or(20)
.max(20);
eprintln!(
- " {:10} {:>5} {:>12} {}",
+ " {:10} {:>5} {:>12} DESCRIPTION",
"NAME",
"DOWNLOADS",
"RATING",
"LANGUAGE",
- "DESCRIPTION",
width = name_w,
);
eprintln!(
diff --git a/crates/specforge-cli/tests/edge_cases.rs b/crates/specforge-cli/tests/edge_cases.rs
index 63577a7..a0f4989 100644
--- a/crates/specforge-cli/tests/edge_cases.rs
+++ b/crates/specforge-cli/tests/edge_cases.rs
@@ -504,7 +504,7 @@ fn edge_case_ref_only_schema() {
);
let doc = parse_resolve_inline(yaml);
assert!(
- doc.schemas.models.len() >= 1,
+ !doc.schemas.models.is_empty(),
"expected at least 1 schema"
);
assert!(
diff --git a/crates/specforge-cli/tests/sdk_integration.rs b/crates/specforge-cli/tests/sdk_integration.rs
index 5129a8f..ee51b8c 100644
--- a/crates/specforge-cli/tests/sdk_integration.rs
+++ b/crates/specforge-cli/tests/sdk_integration.rs
@@ -189,10 +189,8 @@ fn start_mock() -> (SocketAddr, thread::JoinHandle<()>) {
];
let handle = thread::spawn(move || {
- for conn in listener.incoming() {
- if let Ok(mut stream) = conn {
- handle_request(&mut stream, &pets);
- }
+ for mut stream in listener.incoming().flatten() {
+ handle_request(&mut stream, &pets);
}
});
thread::sleep(Duration::from_millis(20));
diff --git a/crates/specforge-core/src/changelog.rs b/crates/specforge-core/src/changelog.rs
index 2422942..e880686 100644
--- a/crates/specforge-core/src/changelog.rs
+++ b/crates/specforge-core/src/changelog.rs
@@ -590,7 +590,7 @@ fn render_markdown(result: &ChangelogResult, opts: &ChangelogOptions) -> String
let mut out = String::new();
// Header
- out.push_str(&"# Changelog\n\n".to_string());
+ out.push_str("# Changelog\n\n");
out.push_str(&format!("## [{version}] -- {today}\n\n"));
// Suggested version bump
@@ -602,7 +602,7 @@ fn render_markdown(result: &ChangelogResult, opts: &ChangelogOptions) -> String
}
// Summary
- out.push_str(&"### Summary\n\n".to_string());
+ out.push_str("### Summary\n\n");
out.push_str(&format!(
"- **{}** operation(s) changed\n",
result.operation_entries.len()
diff --git a/crates/specforge-core/src/diff.rs b/crates/specforge-core/src/diff.rs
index 5c1b799..a792bfc 100644
--- a/crates/specforge-core/src/diff.rs
+++ b/crates/specforge-core/src/diff.rs
@@ -83,7 +83,62 @@ pub fn format_markdown(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetai
if !schema_diffs.is_empty() { out.push_str("## Schema Changes\n\n"); for d in schema_diffs { out.push_str(&format!("### `{}`\n\n| Change | Property | Details |\n|--------|----------|--------|\n", d.name)); for c in &d.changes { let (icon, det) = match &c.change { PropertyChangeKind::Added { ty } => ("Added", format!("new property (`{ty}`)")), PropertyChangeKind::AddedRequired { ty } => ("Added", format!("new required property (`{ty}`)")), PropertyChangeKind::Removed { ty } => ("Removed", format!("was `{ty}`")), PropertyChangeKind::TypeChanged { old_type, new_type } => ("Changed", format!("`{old_type}` → `{new_type}`")), PropertyChangeKind::RequiredChanged { old_required, new_required } => { let o = if *old_required { "required" } else { "optional" }; let n = if *new_required { "required" } else { "optional" }; ("Changed", format!("{o} → {n}")) } }; out.push_str(&format!("| {icon} | `{}` | {det} |\n", c.property)); } out.push('\n'); } } out.push_str("## Summary\n\n"); out.push_str(&format!("- **{}** breaking change(s)\n- **{}** non-breaking change(s)\n", brk.len(), inf.len())); if !schema_diffs.is_empty() { out.push_str(&format!("- **{}** schema(s) modified\n", schema_diffs.len())); } out }
fn fmt_md(f: &DiffFinding) -> String { fmt_md_msg(&f.message) }
-fn fmt_md_msg(msg: &str) -> String { if let Some(rest) = msg.strip_prefix("IR version changed: ") { return format!("IR version changed: {rest}"); } if let Some(id) = msg.strip_prefix("operation removed: ") { return format!("Operation `{id}` removed"); } if let Some(id) = msg.strip_prefix("operation added: ") { return format!("Operation `{id}` added"); } if let Some(n) = msg.strip_prefix("schema removed: ") { return format!("Schema `{n}` removed"); } if let Some(n) = msg.strip_prefix("schema added: ") { return format!("Schema `{n}` added"); } if let Some(p) = msg.strip_prefix("new required property: ") { return format!("New required property `{p}`"); } if let Some(p) = msg.strip_prefix("new optional property: ") { return format!("New optional property `{p}`"); } if let Some(p) = msg.strip_prefix("property removed: ") { return format!("Property `{p}` removed"); } if let Some(p) = msg.strip_prefix("type changed for property: ") { return format!("Type changed for property `{p}`"); } if let Some(p) = msg.strip_prefix("new required parameter: ") { return format!("New required parameter `{p}`"); } if let Some(p) = msg.strip_prefix("parameter removed: ") { return format!("Parameter `{p}` removed"); } if let Some(p) = msg.strip_prefix("response removed: ") { return format!("Response `{p}` removed"); } if let Some(p) = msg.strip_prefix("required changed for property: ") { return format!("Required status changed for property `{p}`"); } if msg == "new required request body" { return "New required request body".into(); } let mut c = msg.chars(); match c.next() { None => String::new(), Some(ch) => { let mut s = ch.to_uppercase().to_string(); s.extend(c); s } } }
+fn fmt_md_msg(msg: &str) -> String {
+ // Recognized diff messages are rewritten into human-readable Markdown.
+ // Each prefix maps to a templated sentence; anything else is returned
+ // with its first character uppercased.
+ if let Some(rest) = msg.strip_prefix("IR version changed: ") {
+ return format!("IR version changed: {rest}");
+ }
+ if let Some(id) = msg.strip_prefix("operation removed: ") {
+ return format!("Operation `{id}` removed");
+ }
+ if let Some(id) = msg.strip_prefix("operation added: ") {
+ return format!("Operation `{id}` added");
+ }
+ if let Some(n) = msg.strip_prefix("schema removed: ") {
+ return format!("Schema `{n}` removed");
+ }
+ if let Some(n) = msg.strip_prefix("schema added: ") {
+ return format!("Schema `{n}` added");
+ }
+ if let Some(p) = msg.strip_prefix("new required property: ") {
+ return format!("New required property `{p}`");
+ }
+ if let Some(p) = msg.strip_prefix("new optional property: ") {
+ return format!("New optional property `{p}`");
+ }
+ if let Some(p) = msg.strip_prefix("property removed: ") {
+ return format!("Property `{p}` removed");
+ }
+ if let Some(p) = msg.strip_prefix("type changed for property: ") {
+ return format!("Type changed for property `{p}`");
+ }
+ if let Some(p) = msg.strip_prefix("new required parameter: ") {
+ return format!("New required parameter `{p}`");
+ }
+ if let Some(p) = msg.strip_prefix("parameter removed: ") {
+ return format!("Parameter `{p}` removed");
+ }
+ if let Some(p) = msg.strip_prefix("response removed: ") {
+ return format!("Response `{p}` removed");
+ }
+ if let Some(p) = msg.strip_prefix("required changed for property: ") {
+ return format!("Required status changed for property `{p}`");
+ }
+ if msg == "new required request body" {
+ return "New required request body".into();
+ }
+ let mut c = msg.chars();
+ match c.next() {
+ None => String::new(),
+ Some(ch) => {
+ let mut s = ch.to_uppercase().to_string();
+ s.extend(c);
+ s
+ }
+ }
+}
pub fn format_json(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail]) -> Result { let breaking: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).cloned().collect(); let info: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Info).cloned().collect(); serde_json::to_string_pretty(&DiffJsonOutput { breaking, info, schema_diffs: schema_diffs.to_vec(), summary: DiffSummary { breaking_count: findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(), info_count: findings.iter().filter(|f| f.severity == DiffSeverity::Info).count() } }) }
diff --git a/crates/specforge-core/src/profiler.rs b/crates/specforge-core/src/profiler.rs
index 8ace5b0..625d005 100644
--- a/crates/specforge-core/src/profiler.rs
+++ b/crates/specforge-core/src/profiler.rs
@@ -365,7 +365,7 @@ pub fn format_text(report: &ProfileReport) -> String {
out.push_str(&format!(" Requests: {}\n", r.total_requests));
out.push_str(&format!(" Success/Error: {} / {}\n", r.success_count, r.error_count));
out.push_str(&format!(" Error rate: {:.1}%\n", r.error_rate * 100.0));
- out.push_str(&" Latency (ms):\n".to_string());
+ out.push_str(" Latency (ms):\n");
out.push_str(&format!(" avg: {:.1}\n", r.avg_latency_ms));
out.push_str(&format!(" p50: {:.1}\n", r.p50_latency_ms));
out.push_str(&format!(" p95: {:.1}\n", r.p95_latency_ms));
@@ -425,7 +425,7 @@ pub fn format_markdown(report: &ProfileReport) -> String {
out.push_str(&format!("**Overall throughput:** {:.1} rps\n\n", report.overall_throughput_rps));
if !report.recommendations.is_empty() {
- out.push_str(&"## Recommendations\n\n".to_string());
+ out.push_str("## Recommendations\n\n");
for (i, rec) in report.recommendations.iter().enumerate() {
out.push_str(&format!("{}. {}\n", i + 1, rec));
}
diff --git a/crates/specforge-core/src/resolve.rs b/crates/specforge-core/src/resolve.rs
index a4b6712..8c59ca6 100644
--- a/crates/specforge-core/src/resolve.rs
+++ b/crates/specforge-core/src/resolve.rs
@@ -526,8 +526,8 @@ fn ref_or_boxed_schema_to_type(schema_or: &ReferenceOr>) -> Type {
/// Handle a `Box>` — used for `additionalProperties` and
/// array `items`. (openapiv3 is inconsistent about which side the Box is on.)
-fn boxed_ref_or_schema_to_type(boxed: &Box>) -> Type {
- match boxed.as_ref() {
+fn boxed_ref_or_schema_to_type(boxed: &ReferenceOr) -> Type {
+ match boxed {
ReferenceOr::Item(s) => schema_to_type(s).unwrap_or(Type::Unknown),
ReferenceOr::Reference { reference } => Type::Reference {
name: ref_name(reference),
diff --git a/crates/specforge-core/src/spec.rs b/crates/specforge-core/src/spec.rs
index 13c24e1..c6b63e1 100644
--- a/crates/specforge-core/src/spec.rs
+++ b/crates/specforge-core/src/spec.rs
@@ -376,10 +376,10 @@ pub fn scan_versions(dir: &Path) -> Vec {
/// Check if a file looks like an OpenAPI spec by extension.
fn is_spec_file(path: &Path) -> bool {
- match path.extension().and_then(|e| e.to_str()) {
- Some("yaml" | "yml" | "json") => true,
- _ => false,
- }
+ matches!(
+ path.extension().and_then(|e| e.to_str()),
+ Some("yaml" | "yml" | "json")
+ )
}
/// Scan a single subdirectory for a spec file and extract its version.
diff --git a/crates/specforge-core/src/testgen.rs b/crates/specforge-core/src/testgen.rs
index b26c5f6..86c04ab 100644
--- a/crates/specforge-core/src/testgen.rs
+++ b/crates/specforge-core/src/testgen.rs
@@ -198,7 +198,7 @@ fn generate_ts_example(ty: &Type, schemas: &indexmap::IndexMap, i
Scalar::Uuid => r#""550e8400-e29b-41d4-a716-446655440000""#.to_string(),
Scalar::Integer | Scalar::Integer64 => "1".to_string(),
Scalar::Float => "1.0".to_string(),
- Scalar::Boolean | Scalar::Base64 | Scalar::Binary => "true".to_string(),
+ Scalar::Boolean | Scalar::Base64 => "true".to_string(),
Scalar::Binary => "new Uint8Array([1,2,3])".to_string(),
_ => "String".to_string(),
},
@@ -349,7 +349,7 @@ fn generate_go_example(ty: &Type, schemas: &indexmap::IndexMap) -
Scalar::Uuid => "\"550e8400-e29b-41d4-a716-446655440000\"".to_string(),
Scalar::Integer | Scalar::Integer64 => "1".to_string(),
Scalar::Float => "1.0".to_string(),
- Scalar::Boolean | Scalar::Base64 | Scalar::Binary => "true".to_string(),
+ Scalar::Boolean | Scalar::Base64 => "true".to_string(),
Scalar::Binary => "new Uint8Array([1,2,3])".to_string(),
_ => "String".to_string(),
},
@@ -511,7 +511,7 @@ fn generate_rust_example(ty: &Type, schemas: &indexmap::IndexMap)
Scalar::Uuid => r#""550e8400-e29b-41d4-a716-446655440000""#.to_string(),
Scalar::Integer | Scalar::Integer64 => "1".to_string(),
Scalar::Float => "1.0".to_string(),
- Scalar::Boolean | Scalar::Base64 | Scalar::Binary => "true".to_string(),
+ Scalar::Boolean | Scalar::Base64 => "true".to_string(),
Scalar::Binary => "new Uint8Array([1,2,3])".to_string(),
_ => "String".to_string(),
},
diff --git a/crates/specforge-go/src/lib.rs b/crates/specforge-go/src/lib.rs
index 2025162..51f0899 100644
--- a/crates/specforge-go/src/lib.rs
+++ b/crates/specforge-go/src/lib.rs
@@ -298,7 +298,7 @@ fn is_struct_model(o: &ObjectModel) -> bool {
fn emit_models(pkg: &str, doc: &Document) -> String {
let mut out = String::new();
- out.push_str(&"// Code generated by specforge. DO NOT EDIT.\n\n".to_string());
+ out.push_str("// Code generated by specforge. DO NOT EDIT.\n\n");
out.push_str(&format!("package {pkg}\n\n"));
// Check if any model needs json + fmt imports: discriminated unions use
@@ -2284,14 +2284,14 @@ func (e ValidationErrors) Error() string {
out.push_str(&format!(
"\t\tif val, exists := obj[{field_lit}]; exists {{\n"
));
- out.push_str(&"\t\t\tif s, ok := val.(string); ok {\n".to_string());
- out.push_str(&"\t\t\t\tvalid := false\n".to_string());
+ out.push_str("\t\t\tif s, ok := val.(string); ok {\n");
+ out.push_str("\t\t\t\tvalid := false\n");
for allowed in &vals {
out.push_str(&format!(
"\t\t\t\tif s == {allowed} {{ valid = true }}\n"
));
}
- out.push_str(&"\t\t\t\tif !valid {\n".to_string());
+ out.push_str("\t\t\t\tif !valid {\n");
out.push_str(&format!(
"\t\t\t\t\terrs = append(errs, ValidationError{{Path: {field_lit}, Message: fmt.Sprintf(\"invalid enum value: %s\", s)}})\n"
));
@@ -2959,8 +2959,8 @@ fn emit_union_from_map(
out.push_str(&format!("\tcase {}:\n", go_string_lit(&val)));
out.push_str(&format!("\t\tvar v {arm_ty}\n"));
out.push_str("\t\tif b, err := json.Marshal(m); err == nil {\n");
- out.push_str(&"\t\t\tif err := json.Unmarshal(b, &v); err == nil {\n".to_string());
- out.push_str(&"\t\t\t\treturn v, nil\n".to_string());
+ out.push_str("\t\t\tif err := json.Unmarshal(b, &v); err == nil {\n");
+ out.push_str("\t\t\t\treturn v, nil\n");
out.push_str("\t\t\t}\n");
out.push_str("\t\t}\n");
out.push_str(&format!("\t\treturn v, fmt.Errorf(\"failed to unmarshal {arm_ty}\")\n"));
diff --git a/crates/specforge-rust/src/lib.rs b/crates/specforge-rust/src/lib.rs
index 461de78..2245467 100644
--- a/crates/specforge-rust/src/lib.rs
+++ b/crates/specforge-rust/src/lib.rs
@@ -2464,7 +2464,7 @@ fn value_type_name(v: &Value) -> &'static str {
out.push_str(" if !v.is_object() {\n");
out.push_str(" errors.push(ValidationError {\n");
out.push_str(" path: String::new(),\n");
- out.push_str(&" message: format!(\"expected object, got {}\", value_type_name(v)),\n".to_string());
+ out.push_str(" message: format!(\"expected object, got {}\", value_type_name(v)),\n");
out.push_str(" });\n");
out.push_str(" }\n");
} else {
@@ -3323,46 +3323,6 @@ fn emit_method(op: &Operation) -> String {
/// Generate the full doc comment block + deprecation attribute for a method.
-fn rust_method_doc_comment(op: &Operation, fn_name: &str) -> String {
- let mut doc = String::new();
- if let Some(s) = &op.summary { doc.push_str(&rust_doc(s)); }
- else { doc.push_str(&format!("/// {} {}.\n", op.method.upper(), op.path)); }
- if let Some(d) = &op.description { for line in d.lines() { doc.push_str(&format!("/// {line}\n")); } }
- let all_params: Vec<&specforge_core::Parameter> = op.parameters.iter().filter(|p| matches!(p.location, ParamLocation::Path | ParamLocation::Query)).collect();
- let has_param_desc = all_params.iter().any(|p| p.description.is_some()) || op.request_body.as_ref().and_then(|b| b.description.as_deref()).is_some();
- if has_param_desc {
- doc.push_str("///\n/// # Arguments\n");
- for p in &all_params {
- let ident = snake(&p.name);
- let suffix = if p.required { String::new() } else { " (optional)".to_string() };
- if let Some(desc) = &p.description { doc.push_str(&format!("/// * `{ident}` - {desc}{suffix}\n")); }
- else { doc.push_str(&format!("/// * `{ident}`{suffix}\n")); }
- }
- if let Some(rb) = &op.request_body {
- let suffix = if rb.required { String::new() } else { " (optional)".to_string() };
- if let Some(desc) = &rb.description { doc.push_str(&format!("/// * `body` - {desc}{suffix}\n")); }
- else { doc.push_str(&format!("/// * `body`{suffix}\n")); }
- }
- }
- let success_desc = op.responses.iter().filter(|r| r.status.starts_with('2')).min_by_key(|r| r.status.clone()).and_then(|r| r.description.clone());
- if let Some(desc) = &success_desc { doc.push_str(&format!("///\n/// # Returns\n/// {desc}\n")); }
- else if success_body(op).is_some() { doc.push_str("///\n/// # Returns\n/// The response body\n"); }
- let error_responses: Vec<&specforge_core::Response> = op.responses.iter().filter(|r| !r.status.starts_with('2') && r.status != "*").collect();
- if !error_responses.is_empty() {
- doc.push_str("///\n/// # Errors\n");
- for r in &error_responses {
- let status = &r.status;
- if let Some(desc) = &r.description { doc.push_str(&format!("/// Returns `Error::Http` with status {status} for {desc}.\n")); }
- else { doc.push_str(&format!("/// Returns `Error::Http` with status {status}.\n")); }
- }
- }
- if is_operation_deprecated(op) {
- if let Some(alt) = rust_deprecation_alternative(op) { doc.push_str(&format!("#[deprecated(note = \"Use {alt} instead\")]\n")); }
- else { doc.push_str(&format!("#[deprecated(note = \"Use {} instead\")]\n", fn_name)); }
- }
- doc
-}
-
fn build_rust_path(path: &str, path_params: &[&specforge_core::Parameter]) -> String {
// Use format! with placeholders; every segment is stringified first so ints
// and enums path-encode cleanly.
From e96ab36544ddaf6c5daf8acbe049dcd9e9f10640 Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 30 Jul 2026 08:09:24 +0000
Subject: [PATCH 03/19] core: Make Scalar matches exhaustive + use sort_by_key
- testgen.rs: remove now-unreachable _ catch-alls (all 9 Scalar variants
are covered after the Binary split) and give Binary its own assertion
- analyzer.rs, marketplace.rs: descending sorts via sort_by_key(Reverse(..))
---
crates/specforge-core/src/analyzer.rs | 5 +++--
crates/specforge-core/src/marketplace.rs | 9 +++++----
crates/specforge-core/src/testgen.rs | 9 ++++-----
3 files changed, 12 insertions(+), 11 deletions(-)
diff --git a/crates/specforge-core/src/analyzer.rs b/crates/specforge-core/src/analyzer.rs
index d158f67..6c37914 100644
--- a/crates/specforge-core/src/analyzer.rs
+++ b/crates/specforge-core/src/analyzer.rs
@@ -1,4 +1,5 @@
use crate::ir::{Document, Model, Type};
+use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, serde::Serialize)]
@@ -55,7 +56,7 @@ pub fn analyze_spec(doc: &Document) -> AnalysisReport {
if obj.properties.len() > 20 { report.large_schemas.push((name.clone(), obj.properties.len())); }
}
}
- report.large_schemas.sort_by(|a, b| b.1.cmp(&a.1));
+ report.large_schemas.sort_by_key(|a| Reverse(a.1));
let mut fps: HashMap, Vec> = HashMap::new();
for (name, model) in doc.schemas.iter() {
if let Model::Object(obj) = model {
@@ -78,7 +79,7 @@ pub fn analyze_spec(doc: &Document) -> AnalysisReport {
let depth = ref_depth(name, &ref_graph, &mut memo);
if depth > 2 { report.deep_refs.push((name.clone(), depth)); }
}
- report.deep_refs.sort_by(|a, b| b.1.cmp(&a.1));
+ report.deep_refs.sort_by_key(|a| Reverse(a.1));
if !report.unused_schemas.is_empty() {
report.recommendations.push(format!("Remove {} unused schema(s): {}", report.unused_schemas.len(), report.unused_schemas.join(", ")));
}
diff --git a/crates/specforge-core/src/marketplace.rs b/crates/specforge-core/src/marketplace.rs
index 24fe69c..28f783d 100644
--- a/crates/specforge-core/src/marketplace.rs
+++ b/crates/specforge-core/src/marketplace.rs
@@ -5,6 +5,7 @@
use anyhow::{bail, Context as _};
use serde::{Deserialize, Serialize};
+use std::cmp::Reverse;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
@@ -56,7 +57,7 @@ impl MarketplaceIndex {
map.insert(&entry.name, entry);
}
self.entries = map.into_values().cloned().collect();
- self.entries.sort_by(|a, b| b.downloads.cmp(&a.downloads));
+ self.entries.sort_by_key(|a| Reverse(a.downloads));
}
/// Search entries by a free-text query (matches name, description, tags, author).
@@ -82,7 +83,7 @@ impl MarketplaceIndex {
/// Return all entries sorted by downloads (descending).
pub fn sorted_by_downloads(&self) -> Vec<&SpecEntry> {
let mut v: Vec<&SpecEntry> = self.entries.iter().collect();
- v.sort_by(|a, b| b.downloads.cmp(&a.downloads));
+ v.sort_by_key(|a| Reverse(a.downloads));
v
}
@@ -191,7 +192,7 @@ impl PluginIndex {
map.insert(&entry.name, entry);
}
self.plugins = map.into_values().cloned().collect();
- self.plugins.sort_by(|a, b| b.downloads.cmp(&a.downloads));
+ self.plugins.sort_by_key(|a| Reverse(a.downloads));
}
/// Search plugins by a free-text query (matches name, description, language, author).
@@ -217,7 +218,7 @@ impl PluginIndex {
/// Return all plugins sorted by downloads (descending).
pub fn sorted_by_downloads(&self) -> Vec<&PluginEntry> {
let mut v: Vec<&PluginEntry> = self.plugins.iter().collect();
- v.sort_by(|a, b| b.downloads.cmp(&a.downloads));
+ v.sort_by_key(|a| Reverse(a.downloads));
v
}
diff --git a/crates/specforge-core/src/testgen.rs b/crates/specforge-core/src/testgen.rs
index 86c04ab..7cc8610 100644
--- a/crates/specforge-core/src/testgen.rs
+++ b/crates/specforge-core/src/testgen.rs
@@ -181,10 +181,12 @@ fn ts_assertion(ty: &Type) -> String {
Scalar::Integer | Scalar::Integer64 | Scalar::Float => {
"console.assert(typeof result === 'number', 'expected number')".to_string()
}
- Scalar::Boolean | Scalar::Base64 | Scalar::Binary => {
+ Scalar::Boolean | Scalar::Base64 => {
"console.assert(typeof result === 'boolean', 'expected boolean')".to_string()
}
- _ => "String".to_string(),
+ Scalar::Binary => {
+ "console.assert(result !== undefined, 'expected bytes')".to_string()
+ }
},
_ => "console.assert(result !== undefined, 'expected result')".to_string(),
}
@@ -200,7 +202,6 @@ fn generate_ts_example(ty: &Type, schemas: &indexmap::IndexMap, i
Scalar::Float => "1.0".to_string(),
Scalar::Boolean | Scalar::Base64 => "true".to_string(),
Scalar::Binary => "new Uint8Array([1,2,3])".to_string(),
- _ => "String".to_string(),
},
Type::StringEnum { variants, .. } => {
if let Some(first) = variants.first() {
@@ -351,7 +352,6 @@ fn generate_go_example(ty: &Type, schemas: &indexmap::IndexMap) -
Scalar::Float => "1.0".to_string(),
Scalar::Boolean | Scalar::Base64 => "true".to_string(),
Scalar::Binary => "new Uint8Array([1,2,3])".to_string(),
- _ => "String".to_string(),
},
Type::StringEnum { variants, .. } => {
if let Some(first) = variants.first() {
@@ -513,7 +513,6 @@ fn generate_rust_example(ty: &Type, schemas: &indexmap::IndexMap)
Scalar::Float => "1.0".to_string(),
Scalar::Boolean | Scalar::Base64 => "true".to_string(),
Scalar::Binary => "new Uint8Array([1,2,3])".to_string(),
- _ => "String".to_string(),
},
Type::StringEnum { variants, .. } => {
if let Some(first) = variants.first() {
From 8e796b67ec9a045e22f8a119a3997418745927ae Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 30 Jul 2026 08:12:23 +0000
Subject: [PATCH 04/19] ts/runtime: Convert file list to vec! macro + cleanup
- runtime.rs: collect() now builds the file list with vec![] instead of
Vec::new() + 18 pushes (clearer intent)
- schema31.rs: drop redundant borrow in to_value() in test
- apply remaining clippy autofixes (collapsed if-lets)
---
crates/specforge-core/src/schema31.rs | 2 +-
crates/specforge-ts/src/runtime.rs | 40 +++++++++++++--------------
2 files changed, 21 insertions(+), 21 deletions(-)
diff --git a/crates/specforge-core/src/schema31.rs b/crates/specforge-core/src/schema31.rs
index de3060e..c1c0099 100644
--- a/crates/specforge-core/src/schema31.rs
+++ b/crates/specforge-core/src/schema31.rs
@@ -88,7 +88,7 @@ mod tests {
#[test]
fn roundtrip() {
let json = serde_json::json!({"type": ["string", "null"], "const": "yes", "x-custom": "value"});
- let s = serde_json::to_value(&Schema31::from_json(&json).unwrap()).unwrap();
+ let s = serde_json::to_value(Schema31::from_json(&json).unwrap()).unwrap();
assert_eq!(s["type"], serde_json::json!(["string", "null"]));
}
}
diff --git a/crates/specforge-ts/src/runtime.rs b/crates/specforge-ts/src/runtime.rs
index 5375a11..fec7ee1 100644
--- a/crates/specforge-ts/src/runtime.rs
+++ b/crates/specforge-ts/src/runtime.rs
@@ -27,26 +27,26 @@ pub fn emit(doc: &Document, out_dir: &Path) -> std::io::Result> {
/// Collect all runtime files (relative path, absolute path, content) for parallel writing.
pub fn collect(doc: &Document, out_dir: &Path) -> std::io::Result> {
let src = out_dir.join("src");
- let mut files = Vec::new();
-
- files.push((path_str(&src.join("errors.ts"), out_dir), src.join("errors.ts"), errors_source()));
- files.push((path_str(&src.join("auth.ts"), out_dir), src.join("auth.ts"), auth_source(doc)));
- files.push((path_str(&src.join("retry.ts"), out_dir), src.join("retry.ts"), retry_source()));
- files.push((path_str(&src.join("paginate.ts"), out_dir), src.join("paginate.ts"), paginate_source()));
- files.push((path_str(&src.join("concurrency.ts"), out_dir), src.join("concurrency.ts"), concurrency_source()));
- files.push((path_str(&src.join("dedup.ts"), out_dir), src.join("dedup.ts"), dedup_source()));
- files.push((path_str(&src.join("idempotency.ts"), out_dir), src.join("idempotency.ts"), idempotency_source()));
- files.push((path_str(&src.join("middleware.ts"), out_dir), src.join("middleware.ts"), middleware_source()));
- files.push((path_str(&src.join("interceptors.ts"), out_dir), src.join("interceptors.ts"), interceptors_source()));
- files.push((path_str(&src.join("streaming.ts"), out_dir), src.join("streaming.ts"), streaming_source()));
- files.push((path_str(&src.join("validate.ts"), out_dir), src.join("validate.ts"), validate_source(doc)));
- files.push((path_str(&src.join("cache.ts"), out_dir), src.join("cache.ts"), cache_source()));
- files.push((path_str(&src.join("ratelimit.ts"), out_dir), src.join("ratelimit.ts"), ratelimit_source()));
- files.push((path_str(&src.join("telemetry.ts"), out_dir), src.join("telemetry.ts"), telemetry_source()));
- files.push((path_str(&src.join("logging.ts"), out_dir), src.join("logging.ts"), logging_source()));
- files.push((path_str(&src.join("validation-middleware.ts"), out_dir), src.join("validation-middleware.ts"), validation_middleware_source()));
- files.push((path_str(&src.join("service_container.ts"), out_dir), src.join("service_container.ts"), service_container_source()));
- files.push((path_str(&src.join("client.ts"), out_dir), src.join("client.ts"), client_source(doc)));
+ let files = vec![
+ (path_str(&src.join("errors.ts"), out_dir), src.join("errors.ts"), errors_source()),
+ (path_str(&src.join("auth.ts"), out_dir), src.join("auth.ts"), auth_source(doc)),
+ (path_str(&src.join("retry.ts"), out_dir), src.join("retry.ts"), retry_source()),
+ (path_str(&src.join("paginate.ts"), out_dir), src.join("paginate.ts"), paginate_source()),
+ (path_str(&src.join("concurrency.ts"), out_dir), src.join("concurrency.ts"), concurrency_source()),
+ (path_str(&src.join("dedup.ts"), out_dir), src.join("dedup.ts"), dedup_source()),
+ (path_str(&src.join("idempotency.ts"), out_dir), src.join("idempotency.ts"), idempotency_source()),
+ (path_str(&src.join("middleware.ts"), out_dir), src.join("middleware.ts"), middleware_source()),
+ (path_str(&src.join("interceptors.ts"), out_dir), src.join("interceptors.ts"), interceptors_source()),
+ (path_str(&src.join("streaming.ts"), out_dir), src.join("streaming.ts"), streaming_source()),
+ (path_str(&src.join("validate.ts"), out_dir), src.join("validate.ts"), validate_source(doc)),
+ (path_str(&src.join("cache.ts"), out_dir), src.join("cache.ts"), cache_source()),
+ (path_str(&src.join("ratelimit.ts"), out_dir), src.join("ratelimit.ts"), ratelimit_source()),
+ (path_str(&src.join("telemetry.ts"), out_dir), src.join("telemetry.ts"), telemetry_source()),
+ (path_str(&src.join("logging.ts"), out_dir), src.join("logging.ts"), logging_source()),
+ (path_str(&src.join("validation-middleware.ts"), out_dir), src.join("validation-middleware.ts"), validation_middleware_source()),
+ (path_str(&src.join("service_container.ts"), out_dir), src.join("service_container.ts"), service_container_source()),
+ (path_str(&src.join("client.ts"), out_dir), src.join("client.ts"), client_source(doc)),
+ ];
Ok(files)
}
From 9459aac5f607ff38a439cfbfd9ce2996e608c8a1 Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 30 Jul 2026 21:41:58 +0000
Subject: [PATCH 05/19] core: Collapse nested if-lets + extract test type
aliases
- resolve.rs, models.rs, rust/lib.rs: collapse 3-deep if-let chains
- cli/main.rs: sort_by_key(Reverse) for marketplace entry sort
- e2e_smoke, e2e_advanced: ParsedRequest/LangLeg type aliases for
readability of complex tuple signatures
- marketplace.rs: drop unused mut in test
Clippy warnings now: 4 (all deliberate non-fixes).
---
crates/specforge-cli/src/main.rs | 2 +-
crates/specforge-cli/tests/e2e_advanced.rs | 5 +-
crates/specforge-cli/tests/e2e_smoke.rs | 10 +++-
crates/specforge-core/src/marketplace.rs | 2 +-
crates/specforge-core/src/resolve.rs | 68 ++++++++++------------
crates/specforge-rust/src/lib.rs | 12 ++--
crates/specforge-ts/src/models.rs | 24 ++++----
7 files changed, 62 insertions(+), 61 deletions(-)
diff --git a/crates/specforge-cli/src/main.rs b/crates/specforge-cli/src/main.rs
index c736299..dc9d2c4 100644
--- a/crates/specforge-cli/src/main.rs
+++ b/crates/specforge-cli/src/main.rs
@@ -2592,7 +2592,7 @@ fn run_market_add(index: &MarketplaceIndex, cli: &MarketAddArgs) -> Result<()> {
// Build a new index: start from existing built-in entries and append.
let mut new_index = index.clone();
new_index.entries.push(entry);
- new_index.entries.sort_by(|a, b| b.downloads.cmp(&a.downloads));
+ new_index.entries.sort_by_key(|a| std::cmp::Reverse(a.downloads));
let json =
serde_json::to_string_pretty(&new_index).context("failed to serialize marketplace index")?;
diff --git a/crates/specforge-cli/tests/e2e_advanced.rs b/crates/specforge-cli/tests/e2e_advanced.rs
index 29763fc..863c5cb 100644
--- a/crates/specforge-cli/tests/e2e_advanced.rs
+++ b/crates/specforge-cli/tests/e2e_advanced.rs
@@ -10,6 +10,9 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread;
+
+/// A parsed HTTP request: method, path, headers, body.
+type ParsedRequest = (String, String, HashMap, Vec);
use std::time::Duration;
// ─── Shared helpers (mirrored lightly from e2e_smoke) ────────────────────────
@@ -98,7 +101,7 @@ fn write_response(stream: &mut TcpStream, status: u16, ct: &str, body: &str, ext
stream.flush()
}
-fn parse_http(stream: &mut TcpStream) -> std::io::Result<(String, String, HashMap, Vec)> {
+fn parse_http(stream: &mut TcpStream) -> std::io::Result {
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let mut buf = [0u8; 32768];
diff --git a/crates/specforge-cli/tests/e2e_smoke.rs b/crates/specforge-cli/tests/e2e_smoke.rs
index 91adeab..ae47bf3 100644
--- a/crates/specforge-cli/tests/e2e_smoke.rs
+++ b/crates/specforge-cli/tests/e2e_smoke.rs
@@ -10,6 +10,12 @@ use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
+
+/// A parsed HTTP request: method, path, version, headers, body.
+type ParsedRequest = (String, String, String, HashMap, Vec);
+
+/// A language test leg: its label and the function that runs it for a crate dir.
+type LangLeg = (&'static str, Box Result<(), String>>);
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread;
@@ -45,7 +51,7 @@ fn escape_json(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
-fn parse_request(stream: &mut TcpStream) -> std::io::Result<(String, String, String, HashMap, Vec)> {
+fn parse_request(stream: &mut TcpStream) -> std::io::Result {
stream.set_read_timeout(Some(Duration::from_secs(3)))?;
stream.set_write_timeout(Some(Duration::from_secs(3)))?;
@@ -1024,7 +1030,7 @@ console.log("ts-runtime-ok", all.length, page.items.length);
fn run_langs(
label: &str,
root: &Path,
- legs: &[(&str, Box Result<(), String>>)],
+ legs: &[LangLeg],
) {
let mut failures = Vec::new();
let mut ran = 0;
diff --git a/crates/specforge-core/src/marketplace.rs b/crates/specforge-core/src/marketplace.rs
index 28f783d..dd79731 100644
--- a/crates/specforge-core/src/marketplace.rs
+++ b/crates/specforge-core/src/marketplace.rs
@@ -312,7 +312,7 @@ mod tests {
#[test]
fn search_matches_name() {
- let mut index = MarketplaceIndex {
+ let index = MarketplaceIndex {
entries: vec![sample_entry("github"), sample_entry("stripe")],
};
let results = index.search("github");
diff --git a/crates/specforge-core/src/resolve.rs b/crates/specforge-core/src/resolve.rs
index 8c59ca6..0477429 100644
--- a/crates/specforge-core/src/resolve.rs
+++ b/crates/specforge-core/src/resolve.rs
@@ -246,34 +246,32 @@ fn properties_from_allof_member(
ReferenceOr::Reference { reference } => {
let ref_name = ref_name(reference);
// First, try to find the schema in components and extract inline.
- if let Some(schema_or) = components.schemas.get(&ref_name) {
- if let ReferenceOr::Item(schema) = schema_or {
- if let SchemaKind::Type(OApiType::Object(ObjectType {
- properties,
- required,
- ..
- })) = &schema.schema_kind
- {
- let required_set: std::collections::HashSet =
- required.iter().cloned().collect();
- let props = properties
- .iter()
- .map(|(prop_name, prop_schema_or)| {
- let ty = ref_or_boxed_schema_to_type(prop_schema_or);
- let description = match prop_schema_or {
- ReferenceOr::Item(s) => s.schema_data.description.clone(),
- ReferenceOr::Reference { .. } => None,
- };
- Property {
- name: prop_name.clone(),
- ty,
- required: required_set.contains(prop_name),
- description,
- }
- })
- .collect();
- return Ok((props, required_set));
- }
+ if let Some(ReferenceOr::Item(schema)) = components.schemas.get(&ref_name) {
+ if let SchemaKind::Type(OApiType::Object(ObjectType {
+ properties,
+ required,
+ ..
+ })) = &schema.schema_kind
+ {
+ let required_set: std::collections::HashSet =
+ required.iter().cloned().collect();
+ let props = properties
+ .iter()
+ .map(|(prop_name, prop_schema_or)| {
+ let ty = ref_or_boxed_schema_to_type(prop_schema_or);
+ let description = match prop_schema_or {
+ ReferenceOr::Item(s) => s.schema_data.description.clone(),
+ ReferenceOr::Reference { .. } => None,
+ };
+ Property {
+ name: prop_name.clone(),
+ ty,
+ required: required_set.contains(prop_name),
+ description,
+ }
+ })
+ .collect();
+ return Ok((props, required_set));
}
}
// Fallback: check the already-built registry.
@@ -766,14 +764,12 @@ fn resolve_responses(
body,
});
}
- if let Some(default) = &responses.default {
- if let ReferenceOr::Item(r) = default {
- out.push(Response {
- status: "default".to_string(),
- description: Some(r.description.clone()),
- body: json_body(&r.content),
- });
- }
+ if let Some(ReferenceOr::Item(r)) = &responses.default {
+ out.push(Response {
+ status: "default".to_string(),
+ description: Some(r.description.clone()),
+ body: json_body(&r.content),
+ });
}
Ok(out)
}
diff --git a/crates/specforge-rust/src/lib.rs b/crates/specforge-rust/src/lib.rs
index 2245467..acfd860 100644
--- a/crates/specforge-rust/src/lib.rs
+++ b/crates/specforge-rust/src/lib.rs
@@ -2989,13 +2989,11 @@ fn discriminant_value(
disc: &specforge_core::Discriminator,
) -> Option {
// 1. Try the arm's own property (single-variant string enum).
- if let Some(model) = registry.get(arm_name) {
- if let Model::Object(obj) = model {
- if let Some(p) = obj.properties.iter().find(|p| p.name == prop) {
- if let Type::StringEnum { variants, .. } = &p.ty {
- if variants.len() == 1 {
- return Some(variants[0].clone());
- }
+ if let Some(Model::Object(obj)) = registry.get(arm_name) {
+ if let Some(p) = obj.properties.iter().find(|p| p.name == prop) {
+ if let Type::StringEnum { variants, .. } = &p.ty {
+ if variants.len() == 1 {
+ return Some(variants[0].clone());
}
}
}
diff --git a/crates/specforge-ts/src/models.rs b/crates/specforge-ts/src/models.rs
index 9db9071..0e7363b 100644
--- a/crates/specforge-ts/src/models.rs
+++ b/crates/specforge-ts/src/models.rs
@@ -235,22 +235,20 @@ fn discriminant_literal(
disc: Option<&Discriminator>,
) -> Option {
// 1. Try the arm's own property (single-variant string enum).
- if let Some(model) = registry.get(arm_name) {
- if let Model::Object(obj) = model {
- if let Some(p) = obj.properties.iter().find(|p| p.name == prop) {
- match &p.ty {
- Type::StringEnum { variants, .. } if variants.len() == 1 => {
- return Some(string_literal(&variants[0]));
- }
- Type::Reference { name, .. } => {
- if let Some(Model::Enum(e)) = registry.get(name) {
- if e.variants.len() == 1 {
- return Some(string_literal(&e.variants[0].value));
- }
+ if let Some(Model::Object(obj)) = registry.get(arm_name) {
+ if let Some(p) = obj.properties.iter().find(|p| p.name == prop) {
+ match &p.ty {
+ Type::StringEnum { variants, .. } if variants.len() == 1 => {
+ return Some(string_literal(&variants[0]));
+ }
+ Type::Reference { name, .. } => {
+ if let Some(Model::Enum(e)) = registry.get(name) {
+ if e.variants.len() == 1 {
+ return Some(string_literal(&e.variants[0].value));
}
}
- _ => {}
}
+ _ => {}
}
}
}
From 845361a6987e7ec7db29f083da4f63886f6dfd86 Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 30 Jul 2026 21:45:09 +0000
Subject: [PATCH 06/19] emitters: Replace guarded chars().next().unwrap() with
is_some_and
5 sites across rust/go/ts emitters checked the first char of a generated
identifier with .unwrap(), relying on a separate is_empty() guard above.
Replace with chars().next().is_some_and(|c| ...) so each check is
self-safe and the guard dependency is explicit. No behavior change;
all tests + generated-SDK compile test pass.
Audit of other unwrap()/expect() in non-test source found them guarded
by construction (len()==1, intersection membership, or infallible I/O).
---
crates/specforge-go/src/lib.rs | 2 +-
crates/specforge-rust/src/lib.rs | 6 +++---
crates/specforge-ts/src/name.rs | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/crates/specforge-go/src/lib.rs b/crates/specforge-go/src/lib.rs
index 51f0899..2f02f5a 100644
--- a/crates/specforge-go/src/lib.rs
+++ b/crates/specforge-go/src/lib.rs
@@ -186,7 +186,7 @@ fn pascal(input: &str) -> String {
if out.is_empty() {
return "X".to_string();
}
- if out.chars().next().unwrap().is_ascii_digit() {
+ if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
out.insert(0, 'X');
}
out
diff --git a/crates/specforge-rust/src/lib.rs b/crates/specforge-rust/src/lib.rs
index acfd860..304a876 100644
--- a/crates/specforge-rust/src/lib.rs
+++ b/crates/specforge-rust/src/lib.rs
@@ -186,7 +186,7 @@ fn crate_name(doc: &Document, opts: &GeneratorOptions) -> String {
slug
};
// Crate names can't start with a digit.
- if slug.chars().next().unwrap().is_ascii_digit() {
+ if slug.chars().next().is_some_and(|c| c.is_ascii_digit()) {
format!("sdk_{slug}")
} else {
format!("{slug}_sdk")
@@ -220,7 +220,7 @@ fn pascal(input: &str) -> String {
if out.is_empty() {
return "X".to_string();
}
- if out.chars().next().unwrap().is_ascii_digit() {
+ if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
out.insert(0, 'X');
}
out
@@ -261,7 +261,7 @@ fn snake(input: &str) -> String {
let out = out.trim_matches('_').to_string();
let out = if out.is_empty() {
"x".into()
- } else if out.chars().next().unwrap().is_ascii_digit() {
+ } else if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
format!("n_{out}")
} else {
out
diff --git a/crates/specforge-ts/src/name.rs b/crates/specforge-ts/src/name.rs
index 5c936b5..71f9c82 100644
--- a/crates/specforge-ts/src/name.rs
+++ b/crates/specforge-ts/src/name.rs
@@ -30,7 +30,7 @@ pub fn pascal(input: &str) -> String {
if out.is_empty() {
return "_".to_string();
}
- if out.chars().next().unwrap().is_ascii_digit() {
+ if out.chars().next().is_some_and(|c| c.is_ascii_digit()) {
out.insert(0, '_');
}
out
From 7322e67ec33d54f6157fdb561b6277aa494f418a Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 30 Jul 2026 21:48:25 +0000
Subject: [PATCH 07/19] core/diff: Expand golfed single-line functions for
readability
Reformatted 9 crammed functions (diff_operations, diff_operation,
diff_schemas, diff_schema, type_to_string, type_changed, format_text,
format_colored, format_json, format_markdown, PropertyChange Display)
into properly indented multi-line form. No logic change; all 242 core
tests + 12 diff tests pass.
---
crates/specforge-core/src/diff.rs | 439 +++++++++++++++++++++++++++++-
1 file changed, 426 insertions(+), 13 deletions(-)
diff --git a/crates/specforge-core/src/diff.rs b/crates/specforge-core/src/diff.rs
index a792bfc..41dbfd8 100644
--- a/crates/specforge-core/src/diff.rs
+++ b/crates/specforge-core/src/diff.rs
@@ -13,7 +13,29 @@ impl std::fmt::Display for DiffFinding { fn fmt(&self, f: &mut std::fmt::Formatt
#[derive(Debug, Clone, Serialize)]
pub struct PropertyChange { pub property: String, pub change: PropertyChangeKind }
-impl std::fmt::Display for PropertyChange { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.change { PropertyChangeKind::Added { ty } => write!(f, "+ {}: {} (new property)", self.property, ty), PropertyChangeKind::AddedRequired { ty } => write!(f, "+ {}: {} (new property, required)", self.property, ty), PropertyChangeKind::Removed { ty } => write!(f, "- {}: {} (removed)", self.property, ty), PropertyChangeKind::TypeChanged { old_type, new_type } => write!(f, "~ {}: {} → {}", self.property, old_type, new_type), PropertyChangeKind::RequiredChanged { old_required, new_required } => { let o = if *old_required { "required" } else { "optional" }; let n = if *new_required { "required" } else { "optional" }; write!(f, "~ {}: {} → {} (required changed)", self.property, o, n) } } } }
+impl std::fmt::Display for PropertyChange {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match &self.change {
+ PropertyChangeKind::Added { ty } => {
+ write!(f, "+ {}: {} (new property)", self.property, ty)
+ }
+ PropertyChangeKind::AddedRequired { ty } => {
+ write!(f, "+ {}: {} (new property, required)", self.property, ty)
+ }
+ PropertyChangeKind::Removed { ty } => {
+ write!(f, "- {}: {} (removed)", self.property, ty)
+ }
+ PropertyChangeKind::TypeChanged { old_type, new_type } => {
+ write!(f, "~ {}: {} → {}", self.property, old_type, new_type)
+ }
+ PropertyChangeKind::RequiredChanged { old_required, new_required } => {
+ let o = if *old_required { "required" } else { "optional" };
+ let n = if *new_required { "required" } else { "optional" };
+ write!(f, "~ {}: {} → {} (required changed)", self.property, o, n)
+ }
+ }
+ }
+}
#[derive(Debug, Clone, Serialize)]
pub enum PropertyChangeKind { Added { ty: String }, AddedRequired { ty: String }, Removed { ty: String }, TypeChanged { old_type: String, new_type: String }, RequiredChanged { old_required: bool, new_required: bool } }
#[derive(Debug, Clone, Serialize)]
@@ -28,7 +50,27 @@ pub struct DiffSummary { pub breaking_count: usize, pub info_count: usize }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffFormat { Text, Markdown, Json }
-fn type_to_string(ty: &Type) -> String { match ty { Type::Scalar(s) => match s { crate::ir::Scalar::String => "string".into(), crate::ir::Scalar::DateTime => "date-time".into(), crate::ir::Scalar::Uuid => "uuid".into(), crate::ir::Scalar::Integer => "integer".into(), crate::ir::Scalar::Integer64 => "integer (int64)".into(), crate::ir::Scalar::Float => "number".into(), crate::ir::Scalar::Boolean => "boolean".into(), _ => "other".into() }, Type::Reference { name, .. } => name.clone(), Type::StringEnum { .. } => "string (enum)".into(), Type::Array { .. } => "array".into(), Type::Map { .. } => "object".into(), Type::Composition(_) => "composition".into(), Type::Any => "any".into(), Type::Unknown => "unknown".into() } }
+fn type_to_string(ty: &Type) -> String {
+ match ty {
+ Type::Scalar(s) => match s {
+ crate::ir::Scalar::String => "string".into(),
+ crate::ir::Scalar::DateTime => "date-time".into(),
+ crate::ir::Scalar::Uuid => "uuid".into(),
+ crate::ir::Scalar::Integer => "integer".into(),
+ crate::ir::Scalar::Integer64 => "integer (int64)".into(),
+ crate::ir::Scalar::Float => "number".into(),
+ crate::ir::Scalar::Boolean => "boolean".into(),
+ _ => "other".into(),
+ },
+ Type::Reference { name, .. } => name.clone(),
+ Type::StringEnum { .. } => "string (enum)".into(),
+ Type::Array { .. } => "array".into(),
+ Type::Map { .. } => "object".into(),
+ Type::Composition(_) => "composition".into(),
+ Type::Any => "any".into(),
+ Type::Unknown => "unknown".into(),
+ }
+}
pub fn diff(old: &Document, new: &Document) -> Vec { diff_detailed(old, new).findings }
pub fn diff_detailed(old: &Document, new: &Document) -> DiffResult { let mut findings = Vec::new(); let mut schema_diffs = Vec::new(); diff_ir_version(old, new, &mut findings); diff_operations(old, new, &mut findings); diff_schemas(old, new, &mut findings, &mut schema_diffs); DiffResult { findings, schema_diffs } }
@@ -68,19 +110,367 @@ fn ir_major_version(version: &str) -> u64 {
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
-fn diff_operations(old: &Document, new: &Document, findings: &mut Vec) { let old_ops: std::collections::HashMap = old.operations.iter().map(|op| (op.operation_id.clone(), op)).collect(); let new_ops: std::collections::HashMap = new.operations.iter().map(|op| (op.operation_id.clone(), op)).collect(); for id in old_ops.keys() { if !new_ops.contains_key(id) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("operation removed: {id}"), path: format!("operations.{id}") }); } } for id in new_ops.keys() { if !old_ops.contains_key(id) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("operation added: {id}"), path: format!("operations.{id}") }); } } for id in old_ops.keys() { if let (Some(o), Some(n)) = (old_ops.get(id), new_ops.get(id)) { diff_operation(o, n, findings); } } }
-fn diff_operation(old: &Operation, new: &Operation, findings: &mut Vec) { let path = format!("operations.{}", old.operation_id); for p in &new.parameters { if p.required && !old.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("new required parameter: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } for p in &old.parameters { if !new.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("parameter removed: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } if new.request_body.is_some() && old.request_body.is_none() && new.request_body.as_ref().map(|rb| rb.required).unwrap_or(false) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: "new required request body".into(), path: format!("{path}.requestBody") }); } for r in &old.responses { if !new.responses.iter().any(|x| x.status == r.status) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("response removed: {}", r.status), path: format!("{path}.responses.{}", r.status) }); } } }
-fn diff_schemas(old: &Document, new: &Document, findings: &mut Vec, schema_diffs: &mut Vec) { let old_s: std::collections::HashSet = old.schemas.iter().map(|(k, _)| k.clone()).collect(); let new_s: std::collections::HashSet = new.schemas.iter().map(|(k, _)| k.clone()).collect(); for n in &old_s { if !new_s.contains(n) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("schema removed: {n}"), path: format!("schemas.{n}") }); } } for n in &new_s { if !old_s.contains(n) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("schema added: {n}"), path: format!("schemas.{n}") }); } } for n in old_s.intersection(&new_s) { if let Some(d) = diff_schema(n, old.schemas.get(n).unwrap(), new.schemas.get(n).unwrap(), findings) { schema_diffs.push(d); } } }
-fn diff_schema(name: &str, old: &Model, new: &Model, findings: &mut Vec) -> Option { let (op, np) = match (old, new) { (Model::Object(o), Model::Object(n)) => (&o.properties, &n.properties), _ => return None }; let path = format!("schemas.{name}"); let mut changes = Vec::new(); for p in np { if p.required && !op.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("new required property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::AddedRequired { ty: type_to_string(&p.ty) } }); } } for p in np { if !p.required && !op.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("new optional property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::Added { ty: type_to_string(&p.ty) } }); } } for p in op { if !np.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("property removed: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::Removed { ty: type_to_string(&p.ty) } }); } } for p in op { if let Some(np) = np.iter().find(|x| x.name == p.name) { if type_changed(&p.ty, &np.ty) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("type changed for property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::TypeChanged { old_type: type_to_string(&p.ty), new_type: type_to_string(&np.ty) } }); } else if p.required != np.required { findings.push(DiffFinding { severity: if np.required { DiffSeverity::Breaking } else { DiffSeverity::Info }, message: format!("required changed for property: {} ({} → {})", p.name, if p.required { "required" } else { "optional" }, if np.required { "required" } else { "optional" }), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::RequiredChanged { old_required: p.required, new_required: np.required } }); } } } if changes.is_empty() { None } else { Some(SchemaDiffDetail { name: name.to_string(), changes }) } }
-fn type_changed(old: &Type, new: &Type) -> bool { match (old, new) { (Type::Scalar(a), Type::Scalar(b)) => a != b, (Type::Reference { name: a, .. }, Type::Reference { name: b, .. }) => a != b, (Type::StringEnum { variants: a, .. }, Type::StringEnum { variants: b, .. }) => a != b, (Type::Array { item: a, .. }, Type::Array { item: b, .. }) => type_changed(a, b), _ => std::mem::discriminant(old) != std::mem::discriminant(new) } }
+fn diff_operations(old: &Document, new: &Document, findings: &mut Vec) {
+ let old_ops: std::collections::HashMap =
+ old.operations.iter().map(|op| (op.operation_id.clone(), op)).collect();
+ let new_ops: std::collections::HashMap =
+ new.operations.iter().map(|op| (op.operation_id.clone(), op)).collect();
+
+ // Removed operations are breaking.
+ for id in old_ops.keys() {
+ if !new_ops.contains_key(id) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("operation removed: {id}"),
+ path: format!("operations.{id}"),
+ });
+ }
+ }
+ // Added operations are informational.
+ for id in new_ops.keys() {
+ if !old_ops.contains_key(id) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Info,
+ message: format!("operation added: {id}"),
+ path: format!("operations.{id}"),
+ });
+ }
+ }
+ // Operations present in both may have changed.
+ for id in old_ops.keys() {
+ if let (Some(o), Some(n)) = (old_ops.get(id), new_ops.get(id)) {
+ diff_operation(o, n, findings);
+ }
+ }
+}
+
+fn diff_operation(old: &Operation, new: &Operation, findings: &mut Vec) {
+ let path = format!("operations.{}", old.operation_id);
-pub fn format_text(findings: &[DiffFinding]) -> String { let mut out = String::new(); for finding in findings { match finding.severity { DiffSeverity::Breaking => out.push_str(&format!("breaking: {finding}\n")), DiffSeverity::Info => out.push_str(&format!("info: {finding}\n")) } } let bc = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(); let ic = findings.len() - bc; if !findings.is_empty() { out.push_str(&format!("\n{bc} breaking change(s), {ic} info finding(s)\n")); } out }
+ // New required parameters are breaking.
+ for p in &new.parameters {
+ if p.required && !old.parameters.iter().any(|x| x.name == p.name) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("new required parameter: {}", p.name),
+ path: format!("{path}.params.{}", p.name),
+ });
+ }
+ }
+ // Removed parameters are breaking.
+ for p in &old.parameters {
+ if !new.parameters.iter().any(|x| x.name == p.name) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("parameter removed: {}", p.name),
+ path: format!("{path}.params.{}", p.name),
+ });
+ }
+ }
+ // A newly required request body is breaking.
+ if new.request_body.is_some()
+ && old.request_body.is_none()
+ && new.request_body.as_ref().map(|rb| rb.required).unwrap_or(false)
+ {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: "new required request body".into(),
+ path: format!("{path}.requestBody"),
+ });
+ }
+ // Removed response codes are breaking.
+ for r in &old.responses {
+ if !new.responses.iter().any(|x| x.status == r.status) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("response removed: {}", r.status),
+ path: format!("{path}.responses.{}", r.status),
+ });
+ }
+ }
+}
+fn diff_schemas(
+ old: &Document,
+ new: &Document,
+ findings: &mut Vec,
+ schema_diffs: &mut Vec,
+) {
+ let old_s: std::collections::HashSet =
+ old.schemas.iter().map(|(k, _)| k.clone()).collect();
+ let new_s: std::collections::HashSet =
+ new.schemas.iter().map(|(k, _)| k.clone()).collect();
-pub fn format_colored(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail]) -> String { const RED: &str = "\x1b[31m"; const GREEN: &str = "\x1b[32m"; const YELLOW: &str = "\x1b[33m"; const BOLD: &str = "\x1b[1m"; const DIM: &str = "\x1b[2m"; const RESET: &str = "\x1b[0m"; let mut out = String::new(); for f in findings { match f.severity { DiffSeverity::Breaking => out.push_str(&format!("{RED}{BOLD}breaking{RESET} {RED}{}\n{RESET}", f.message)), DiffSeverity::Info => out.push_str(&format!("{GREEN}{BOLD}info{RESET} {GREEN}{}\n{RESET}", f.message)) } } if !schema_diffs.is_empty() { out.push('\n'); for d in schema_diffs { out.push_str(&format!("{BOLD}Schema {}:{RESET}\n", d.name)); for c in &d.changes { let (col, pre) = match &c.change { PropertyChangeKind::Added { .. } | PropertyChangeKind::AddedRequired { .. } => (GREEN, "+"), PropertyChangeKind::Removed { .. } => (RED, "-"), _ => (YELLOW, "~") }; out.push_str(&format!(" {col}{pre} {c}{RESET}\n")); } } } let bc = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(); let ic = findings.len() - bc; if !findings.is_empty() { out.push_str(&format!("\n{DIM}{bc} breaking change(s), {ic} info finding(s){RESET}\n")); } out }
+ // Removed schemas are breaking.
+ for n in &old_s {
+ if !new_s.contains(n) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("schema removed: {n}"),
+ path: format!("schemas.{n}"),
+ });
+ }
+ }
+ // Added schemas are informational.
+ for n in &new_s {
+ if !old_s.contains(n) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Info,
+ message: format!("schema added: {n}"),
+ path: format!("schemas.{n}"),
+ });
+ }
+ }
+ // Schemas present in both may have property-level changes.
+ for n in old_s.intersection(&new_s) {
+ if let Some(d) = diff_schema(
+ n,
+ old.schemas.get(n).unwrap(),
+ new.schemas.get(n).unwrap(),
+ findings,
+ ) {
+ schema_diffs.push(d);
+ }
+ }
+}
-pub fn format_markdown(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail], old_version: &str, new_version: &str) -> String { let mut out = String::new(); out.push_str(&format!("# API Changes: {old_version} → {new_version}\n\n")); let brk: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).collect(); let inf: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Info).collect(); out.push_str("## Breaking Changes\n\n"); if brk.is_empty() { out.push_str("_None._\n\n"); } else { for f in &brk { out.push_str(&format!("- ❌ {}\n", fmt_md(f))); } out.push('\n'); }
- out.push_str("## Non-Breaking Changes\n\n"); if inf.is_empty() { out.push_str("_None._\n\n"); } else { for f in &inf { out.push_str(&format!("- ✅ {}\n", fmt_md(f))); } out.push('\n'); }
- if !schema_diffs.is_empty() { out.push_str("## Schema Changes\n\n"); for d in schema_diffs { out.push_str(&format!("### `{}`\n\n| Change | Property | Details |\n|--------|----------|--------|\n", d.name)); for c in &d.changes { let (icon, det) = match &c.change { PropertyChangeKind::Added { ty } => ("Added", format!("new property (`{ty}`)")), PropertyChangeKind::AddedRequired { ty } => ("Added", format!("new required property (`{ty}`)")), PropertyChangeKind::Removed { ty } => ("Removed", format!("was `{ty}`")), PropertyChangeKind::TypeChanged { old_type, new_type } => ("Changed", format!("`{old_type}` → `{new_type}`")), PropertyChangeKind::RequiredChanged { old_required, new_required } => { let o = if *old_required { "required" } else { "optional" }; let n = if *new_required { "required" } else { "optional" }; ("Changed", format!("{o} → {n}")) } }; out.push_str(&format!("| {icon} | `{}` | {det} |\n", c.property)); } out.push('\n'); } } out.push_str("## Summary\n\n"); out.push_str(&format!("- **{}** breaking change(s)\n- **{}** non-breaking change(s)\n", brk.len(), inf.len())); if !schema_diffs.is_empty() { out.push_str(&format!("- **{}** schema(s) modified\n", schema_diffs.len())); } out }
+fn diff_schema(
+ name: &str,
+ old: &Model,
+ new: &Model,
+ findings: &mut Vec,
+) -> Option {
+ let (op, np) = match (old, new) {
+ (Model::Object(o), Model::Object(n)) => (&o.properties, &n.properties),
+ _ => return None,
+ };
+ let path = format!("schemas.{name}");
+ let mut changes = Vec::new();
+
+ // New required properties are breaking.
+ for p in np {
+ if p.required && !op.iter().any(|x| x.name == p.name) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("new required property: {}", p.name),
+ path: format!("{path}.{}", p.name),
+ });
+ changes.push(PropertyChange {
+ property: p.name.clone(),
+ change: PropertyChangeKind::AddedRequired { ty: type_to_string(&p.ty) },
+ });
+ }
+ }
+ // New optional properties are informational.
+ for p in np {
+ if !p.required && !op.iter().any(|x| x.name == p.name) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Info,
+ message: format!("new optional property: {}", p.name),
+ path: format!("{path}.{}", p.name),
+ });
+ changes.push(PropertyChange {
+ property: p.name.clone(),
+ change: PropertyChangeKind::Added { ty: type_to_string(&p.ty) },
+ });
+ }
+ }
+ // Removed properties are breaking.
+ for p in op {
+ if !np.iter().any(|x| x.name == p.name) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("property removed: {}", p.name),
+ path: format!("{path}.{}", p.name),
+ });
+ changes.push(PropertyChange {
+ property: p.name.clone(),
+ change: PropertyChangeKind::Removed { ty: type_to_string(&p.ty) },
+ });
+ }
+ }
+ // Type or required-status changes for shared properties.
+ for p in op {
+ if let Some(np) = np.iter().find(|x| x.name == p.name) {
+ if type_changed(&p.ty, &np.ty) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("type changed for property: {}", p.name),
+ path: format!("{path}.{}", p.name),
+ });
+ changes.push(PropertyChange {
+ property: p.name.clone(),
+ change: PropertyChangeKind::TypeChanged {
+ old_type: type_to_string(&p.ty),
+ new_type: type_to_string(&np.ty),
+ },
+ });
+ } else if p.required != np.required {
+ findings.push(DiffFinding {
+ severity: if np.required { DiffSeverity::Breaking } else { DiffSeverity::Info },
+ message: format!(
+ "required changed for property: {} ({} → {})",
+ p.name,
+ if p.required { "required" } else { "optional" },
+ if np.required { "required" } else { "optional" }
+ ),
+ path: format!("{path}.{}", p.name),
+ });
+ changes.push(PropertyChange {
+ property: p.name.clone(),
+ change: PropertyChangeKind::RequiredChanged {
+ old_required: p.required,
+ new_required: np.required,
+ },
+ });
+ }
+ }
+ }
+
+ if changes.is_empty() { None } else { Some(SchemaDiffDetail { name: name.to_string(), changes }) }
+}
+fn type_changed(old: &Type, new: &Type) -> bool {
+ match (old, new) {
+ (Type::Scalar(a), Type::Scalar(b)) => a != b,
+ (Type::Reference { name: a, .. }, Type::Reference { name: b, .. }) => a != b,
+ (Type::StringEnum { variants: a, .. }, Type::StringEnum { variants: b, .. }) => a != b,
+ (Type::Array { item: a, .. }, Type::Array { item: b, .. }) => type_changed(a, b),
+ _ => std::mem::discriminant(old) != std::mem::discriminant(new),
+ }
+}
+
+pub fn format_text(findings: &[DiffFinding]) -> String {
+ let mut out = String::new();
+ for finding in findings {
+ match finding.severity {
+ DiffSeverity::Breaking => out.push_str(&format!("breaking: {finding}\n")),
+ DiffSeverity::Info => out.push_str(&format!("info: {finding}\n")),
+ }
+ }
+ let bc = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count();
+ let ic = findings.len() - bc;
+ if !findings.is_empty() {
+ out.push_str(&format!("\n{bc} breaking change(s), {ic} info finding(s)\n"));
+ }
+ out
+}
+
+pub fn format_colored(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail]) -> String {
+ const RED: &str = "\x1b[31m";
+ const GREEN: &str = "\x1b[32m";
+ const YELLOW: &str = "\x1b[33m";
+ const BOLD: &str = "\x1b[1m";
+ const DIM: &str = "\x1b[2m";
+ const RESET: &str = "\x1b[0m";
+
+ let mut out = String::new();
+ for f in findings {
+ match f.severity {
+ DiffSeverity::Breaking => {
+ out.push_str(&format!("{RED}{BOLD}breaking{RESET} {RED}{}\n{RESET}", f.message))
+ }
+ DiffSeverity::Info => {
+ out.push_str(&format!("{GREEN}{BOLD}info{RESET} {GREEN}{}\n{RESET}", f.message))
+ }
+ }
+ }
+ if !schema_diffs.is_empty() {
+ out.push('\n');
+ for d in schema_diffs {
+ out.push_str(&format!("{BOLD}Schema {}:{RESET}\n", d.name));
+ for c in &d.changes {
+ let (col, pre) = match &c.change {
+ PropertyChangeKind::Added { .. } | PropertyChangeKind::AddedRequired { .. } => (GREEN, "+"),
+ PropertyChangeKind::Removed { .. } => (RED, "-"),
+ _ => (YELLOW, "~"),
+ };
+ out.push_str(&format!(" {col}{pre} {c}{RESET}\n"));
+ }
+ }
+ }
+ let bc = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count();
+ let ic = findings.len() - bc;
+ if !findings.is_empty() {
+ out.push_str(&format!("\n{DIM}{bc} breaking change(s), {ic} info finding(s){RESET}\n"));
+ }
+ out
+}
+
+pub fn format_markdown(
+ findings: &[DiffFinding],
+ schema_diffs: &[SchemaDiffDetail],
+ old_version: &str,
+ new_version: &str,
+) -> String {
+ let mut out = String::new();
+ out.push_str(&format!("# API Changes: {old_version} → {new_version}\n\n"));
+
+ let brk: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).collect();
+ let inf: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Info).collect();
+
+ // Breaking changes section.
+ out.push_str("## Breaking Changes\n\n");
+ if brk.is_empty() {
+ out.push_str("_None._\n\n");
+ } else {
+ for f in &brk {
+ out.push_str(&format!("- ❌ {}\n", fmt_md(f)));
+ }
+ out.push('\n');
+ }
+
+ // Non-breaking changes section.
+ out.push_str("## Non-Breaking Changes\n\n");
+ if inf.is_empty() {
+ out.push_str("_None._\n\n");
+ } else {
+ for f in &inf {
+ out.push_str(&format!("- ✅ {}\n", fmt_md(f)));
+ }
+ out.push('\n');
+ }
+
+ // Schema changes section (table per schema).
+ if !schema_diffs.is_empty() {
+ out.push_str("## Schema Changes\n\n");
+ for d in schema_diffs {
+ out.push_str(&format!(
+ "### `{}`\n\n| Change | Property | Details |\n|--------|----------|--------|\n",
+ d.name
+ ));
+ for c in &d.changes {
+ let (icon, det) = match &c.change {
+ PropertyChangeKind::Added { ty } => ("Added", format!("new property (`{ty}`)")),
+ PropertyChangeKind::AddedRequired { ty } => {
+ ("Added", format!("new required property (`{ty}`)"))
+ }
+ PropertyChangeKind::Removed { ty } => ("Removed", format!("was `{ty}`")),
+ PropertyChangeKind::TypeChanged { old_type, new_type } => {
+ ("Changed", format!("`{old_type}` → `{new_type}`"))
+ }
+ PropertyChangeKind::RequiredChanged { old_required, new_required } => {
+ let o = if *old_required { "required" } else { "optional" };
+ let n = if *new_required { "required" } else { "optional" };
+ ("Changed", format!("{o} → {n}"))
+ }
+ };
+ out.push_str(&format!("| {icon} | `{}` | {det} |\n", c.property));
+ }
+ out.push('\n');
+ }
+ }
+
+ // Summary section.
+ out.push_str("## Summary\n\n");
+ out.push_str(&format!(
+ "- **{}** breaking change(s)\n- **{}** non-breaking change(s)\n",
+ brk.len(),
+ inf.len()
+ ));
+ if !schema_diffs.is_empty() {
+ out.push_str(&format!("- **{}** schema(s) modified\n", schema_diffs.len()));
+ }
+ out
+}
fn fmt_md(f: &DiffFinding) -> String { fmt_md_msg(&f.message) }
fn fmt_md_msg(msg: &str) -> String {
@@ -140,7 +530,30 @@ fn fmt_md_msg(msg: &str) -> String {
}
}
-pub fn format_json(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail]) -> Result { let breaking: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).cloned().collect(); let info: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Info).cloned().collect(); serde_json::to_string_pretty(&DiffJsonOutput { breaking, info, schema_diffs: schema_diffs.to_vec(), summary: DiffSummary { breaking_count: findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(), info_count: findings.iter().filter(|f| f.severity == DiffSeverity::Info).count() } }) }
+pub fn format_json(
+ findings: &[DiffFinding],
+ schema_diffs: &[SchemaDiffDetail],
+) -> Result {
+ let breaking: Vec<_> = findings
+ .iter()
+ .filter(|f| f.severity == DiffSeverity::Breaking)
+ .cloned()
+ .collect();
+ let info: Vec<_> = findings
+ .iter()
+ .filter(|f| f.severity == DiffSeverity::Info)
+ .cloned()
+ .collect();
+ serde_json::to_string_pretty(&DiffJsonOutput {
+ breaking,
+ info,
+ schema_diffs: schema_diffs.to_vec(),
+ summary: DiffSummary {
+ breaking_count: findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(),
+ info_count: findings.iter().filter(|f| f.severity == DiffSeverity::Info).count(),
+ },
+ })
+}
#[cfg(test)]
mod tests {
From 92d31d53346ce2c7ea02076f0fbae70a04e11e71 Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 30 Jul 2026 21:49:33 +0000
Subject: [PATCH 08/19] core: Expand golfed functions in diff/security/graph
- diff.rs: format diff_detailed entry point
- security.rs: expand match arms in scheme_info_from_raw
- graph.rs: expand render_mermaid/render_dot node/edge loops
Readability only; all tests pass.
---
crates/specforge-core/src/diff.rs | 9 +++++-
crates/specforge-core/src/graph.rs | 46 +++++++++++++++++++++++----
crates/specforge-core/src/security.rs | 34 +++++++++++++++++---
3 files changed, 78 insertions(+), 11 deletions(-)
diff --git a/crates/specforge-core/src/diff.rs b/crates/specforge-core/src/diff.rs
index 41dbfd8..dd2afd4 100644
--- a/crates/specforge-core/src/diff.rs
+++ b/crates/specforge-core/src/diff.rs
@@ -72,7 +72,14 @@ fn type_to_string(ty: &Type) -> String {
}
}
pub fn diff(old: &Document, new: &Document) -> Vec { diff_detailed(old, new).findings }
-pub fn diff_detailed(old: &Document, new: &Document) -> DiffResult { let mut findings = Vec::new(); let mut schema_diffs = Vec::new(); diff_ir_version(old, new, &mut findings); diff_operations(old, new, &mut findings); diff_schemas(old, new, &mut findings, &mut schema_diffs); DiffResult { findings, schema_diffs } }
+pub fn diff_detailed(old: &Document, new: &Document) -> DiffResult {
+ let mut findings = Vec::new();
+ let mut schema_diffs = Vec::new();
+ diff_ir_version(old, new, &mut findings);
+ diff_operations(old, new, &mut findings);
+ diff_schemas(old, new, &mut findings, &mut schema_diffs);
+ DiffResult { findings, schema_diffs }
+}
/// Check for IR version mismatches between old and new documents.
/// A major version difference indicates breaking IR schema changes.
diff --git a/crates/specforge-core/src/graph.rs b/crates/specforge-core/src/graph.rs
index befa49a..f7e7d91 100644
--- a/crates/specforge-core/src/graph.rs
+++ b/crates/specforge-core/src/graph.rs
@@ -62,15 +62,49 @@ fn dlbl(l: &str) -> String { l.replace('\\', "\\\\").replace('"', "\\\"").replac
fn render_mermaid(g: &Graph) -> String {
let mut o = String::from("graph TD\n");
- for n in &g.nodes { let id = mid(&n.id); match n.shape { NodeShape::Default => writeln!(o, " {id}[\"{}\"]", mlbl(&n.label)).unwrap(), NodeShape::Diamond => writeln!(o, " {id}{{\"{}\"}}", mlbl(&n.label)).unwrap() } }
- for e in &g.edges { let f = mid(&e.from); let t = mid(&e.to); match &e.label { Some(l) => writeln!(o, " {f} -->|{l}| {t}").unwrap(), None => writeln!(o, " {f} --> {t}").unwrap() } }
+ for n in &g.nodes {
+ let id = mid(&n.id);
+ match n.shape {
+ NodeShape::Default => writeln!(o, " {id}[\"{}\"]", mlbl(&n.label)).unwrap(),
+ NodeShape::Diamond => writeln!(o, " {id}{{\"{}\"}}", mlbl(&n.label)).unwrap(),
+ }
+ }
+ for e in &g.edges {
+ let f = mid(&e.from);
+ let t = mid(&e.to);
+ match &e.label {
+ Some(l) => writeln!(o, " {f} -->|{l}| {t}").unwrap(),
+ None => writeln!(o, " {f} --> {t}").unwrap(),
+ }
+ }
o
}
fn render_dot(g: &Graph, title: &str) -> String {
- let mut o = format!("digraph \"{title}\" {{\n rankdir=TB;\n node [shape=box, style=filled, fillcolor=\"#f0f0f0\"];\n edge [color=\"#555555\"];\n\n");
- for n in &g.nodes { let id = did(&n.id); let l = dlbl(&n.label); match n.shape { NodeShape::Default => writeln!(o, " {id} [label=\"{l}\"];").unwrap(), NodeShape::Diamond => writeln!(o, " {id} [label=\"{l}\", shape=diamond, fillcolor=\"#e8e0f0\"];").unwrap() } }
+ let mut o = format!(
+ "digraph \"{title}\" {{\n rankdir=TB;\n node [shape=box, style=filled, fillcolor=\"#f0f0f0\"];\n edge [color=\"#555555\"];\n\n"
+ );
+ for n in &g.nodes {
+ let id = did(&n.id);
+ let l = dlbl(&n.label);
+ match n.shape {
+ NodeShape::Default => writeln!(o, " {id} [label=\"{l}\"];").unwrap(),
+ NodeShape::Diamond => writeln!(
+ o,
+ " {id} [label=\"{l}\", shape=diamond, fillcolor=\"#e8e0f0\"];"
+ )
+ .unwrap(),
+ }
+ }
o.push('\n');
- for e in &g.edges { let f = did(&e.from); let t = did(&e.to); match &e.label { Some(l) => writeln!(o, " {f} -> {t} [label=\"{l}\"];").unwrap(), None => writeln!(o, " {f} -> {t};").unwrap() } }
- o.push_str("}\n"); o
+ for e in &g.edges {
+ let f = did(&e.from);
+ let t = did(&e.to);
+ match &e.label {
+ Some(l) => writeln!(o, " {f} -> {t} [label=\"{l}\"];").unwrap(),
+ None => writeln!(o, " {f} -> {t};").unwrap(),
+ }
+ }
+ o.push_str("}\n");
+ o
}
diff --git a/crates/specforge-core/src/security.rs b/crates/specforge-core/src/security.rs
index dc2afdf..2a13269 100644
--- a/crates/specforge-core/src/security.rs
+++ b/crates/specforge-core/src/security.rs
@@ -55,10 +55,36 @@ fn scheme_info_from_ir(s: &SecurityScheme) -> SecuritySchemeInfo {
SecurityScheme::ApiKey { header } => SecuritySchemeInfo { name: format!("ApiKey({})", header), kind: "apikey".into(), header: Some(header.clone()), bearer_format: None } }
}
fn scheme_info_from_raw(name: &str, s: &OApiSecurityScheme) -> SecuritySchemeInfo {
- match s { OApiSecurityScheme::HTTP { scheme, bearer_format, .. } => SecuritySchemeInfo { name: name.into(), kind: scheme.to_lowercase(), header: if scheme.eq_ignore_ascii_case("bearer") { Some("Authorization".into()) } else { None }, bearer_format: bearer_format.clone() },
- OApiSecurityScheme::APIKey { name: kn, .. } => SecuritySchemeInfo { name: name.into(), kind: "apikey".into(), header: Some(kn.clone()), bearer_format: None },
- OApiSecurityScheme::OAuth2 { .. } => SecuritySchemeInfo { name: name.into(), kind: "oauth2".into(), header: None, bearer_format: None },
- OApiSecurityScheme::OpenIDConnect { .. } => SecuritySchemeInfo { name: name.into(), kind: "openidconnect".into(), header: None, bearer_format: None } }
+ match s {
+ OApiSecurityScheme::HTTP { scheme, bearer_format, .. } => SecuritySchemeInfo {
+ name: name.into(),
+ kind: scheme.to_lowercase(),
+ header: if scheme.eq_ignore_ascii_case("bearer") {
+ Some("Authorization".into())
+ } else {
+ None
+ },
+ bearer_format: bearer_format.clone(),
+ },
+ OApiSecurityScheme::APIKey { name: kn, .. } => SecuritySchemeInfo {
+ name: name.into(),
+ kind: "apikey".into(),
+ header: Some(kn.clone()),
+ bearer_format: None,
+ },
+ OApiSecurityScheme::OAuth2 { .. } => SecuritySchemeInfo {
+ name: name.into(),
+ kind: "oauth2".into(),
+ header: None,
+ bearer_format: None,
+ },
+ OApiSecurityScheme::OpenIDConnect { .. } => SecuritySchemeInfo {
+ name: name.into(),
+ kind: "openidconnect".into(),
+ header: None,
+ bearer_format: None,
+ },
+ }
}
fn scheme_label(s: &SecurityScheme) -> String { match s { SecurityScheme::HttpBearer => "bearer".into(), SecurityScheme::ApiKey { header } => format!("apikey ({})", header) } }
fn extract_scheme_names(security: &Option>) -> Vec { let Some(reqs) = security else { return Vec::new() }; reqs.iter().flat_map(|r| r.keys().cloned()).collect() }
From 0dcec79b8faf54c101a1a760e22ca032599f03be Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 30 Jul 2026 21:51:58 +0000
Subject: [PATCH 09/19] core/changelog: Gate test-only fn with cfg(test);
expose example plugin
- changelog.rs: build_operation_entries is only used by tests, so gate
it with #[cfg(test)] (removes dead-code warning from the lib build)
- plugin-example: make ReadmePlugin pub so the example is a usable,
importable demonstration rather than an unused struct
---
crates/specforge-core/src/changelog.rs | 1 +
examples/plugin-example/src/lib.rs | 3 ++-
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/crates/specforge-core/src/changelog.rs b/crates/specforge-core/src/changelog.rs
index e880686..a36b0e8 100644
--- a/crates/specforge-core/src/changelog.rs
+++ b/crates/specforge-core/src/changelog.rs
@@ -237,6 +237,7 @@ fn classify_finding(finding: &diff::DiffFinding) -> ChangeImpact {
}
/// Build per-operation changelog entries from the diff between old and new documents.
+#[cfg(test)]
fn build_operation_entries(
old_doc: &Document,
new_doc: &Document,
diff --git a/examples/plugin-example/src/lib.rs b/examples/plugin-example/src/lib.rs
index bb385cd..a67a6c4 100644
--- a/examples/plugin-example/src/lib.rs
+++ b/examples/plugin-example/src/lib.rs
@@ -1,7 +1,8 @@
use specforge_plugin::{GeneratedFile, Plugin, PluginResult};
use serde_json::Value;
-struct ReadmePlugin;
+/// Example plugin that emits a project README summarizing the generated SDK.
+pub struct ReadmePlugin;
impl Default for ReadmePlugin {
fn default() -> Self {
From e714459b3fe45f3398a9e3f1ddcfc3a4152fd2fe Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 30 Jul 2026 21:53:55 +0000
Subject: [PATCH 10/19] core: Document panics on
built_in/generate_demo_spec/migration_guide
Add # Panics sections to the 4 public functions flagged by
missing_panics_doc (marketplace built_in x2, demo spec, deprecation
migration guide). All document 'should never happen' panics on
compile-time-baked data or resolve()-invariant assumptions.
---
crates/specforge-core/src/demo.rs | 6 ++++++
crates/specforge-core/src/deprecation.rs | 6 ++++++
crates/specforge-core/src/marketplace.rs | 10 ++++++++++
3 files changed, 22 insertions(+)
diff --git a/crates/specforge-core/src/demo.rs b/crates/specforge-core/src/demo.rs
index 5b82d53..490eae2 100644
--- a/crates/specforge-core/src/demo.rs
+++ b/crates/specforge-core/src/demo.rs
@@ -6,6 +6,12 @@
//! point for new APIs.
/// Generate the demo Petstore spec as a YAML string.
+///
+/// # Panics
+///
+/// Panics if the internally-built spec fails to serialize to YAML. Since the
+/// spec is constructed from known-valid `serde_json::Value`s, this should never
+/// happen; a panic here indicates an internal bug.
pub fn generate_demo_spec() -> String {
// We build the spec as a serde_json::Value for reliable escaping,
// then serialize to YAML.
diff --git a/crates/specforge-core/src/deprecation.rs b/crates/specforge-core/src/deprecation.rs
index 25e90de..155e7e9 100644
--- a/crates/specforge-core/src/deprecation.rs
+++ b/crates/specforge-core/src/deprecation.rs
@@ -217,6 +217,12 @@ fn extract_alternative(text: &str) -> Option {
///
/// Produces a Markdown document listing deprecated operations, removed
/// operations, new required parameters, and schema changes.
+///
+/// # Panics
+///
+/// Panics if a schema name present in both documents' schema sets is missing
+/// from the schema map of either document. This cannot happen for documents
+/// produced by [`resolve`](crate::resolve), which keep both in sync.
pub fn generate_migration_guide(
old_doc: &Document,
new_doc: &Document,
diff --git a/crates/specforge-core/src/marketplace.rs b/crates/specforge-core/src/marketplace.rs
index dd79731..7ffd21f 100644
--- a/crates/specforge-core/src/marketplace.rs
+++ b/crates/specforge-core/src/marketplace.rs
@@ -41,6 +41,11 @@ impl MarketplaceIndex {
}
/// Return the built-in curated marketplace index bundled with this binary.
+ ///
+ /// # Panics
+ ///
+ /// Panics if the bundled `assets/marketplace-index.json` is malformed. This
+ /// is a compile-time-baked asset, so a panic here indicates a corrupt build.
pub fn built_in() -> Self {
let json = include_str!("../../../assets/marketplace-index.json");
serde_json::from_str(json).expect("bundled marketplace-index.json is valid JSON")
@@ -176,6 +181,11 @@ impl PluginIndex {
}
/// Return the built-in curated plugin index bundled with this binary.
+ ///
+ /// # Panics
+ ///
+ /// Panics if the bundled `assets/plugin-index.json` is malformed. This
+ /// is a compile-time-baked asset, so a panic here indicates a corrupt build.
pub fn built_in() -> Self {
let json = include_str!("../../../assets/plugin-index.json");
serde_json::from_str(json).expect("bundled plugin-index.json is valid JSON")
From f40737c54950c22e8e2efe608d8135717f295b85 Mon Sep 17 00:00:00 2001
From: root
Date: Thu, 30 Jul 2026 21:55:03 +0000
Subject: [PATCH 11/19] core: Expand golfed Display impls + security analyzers
- diff.rs: format DiffSeverity/SchemaDiffDetail Display impls
- security.rs: expand analyze_common_issues/analyze_detailed_issues
(multiple statements-per-line -> readable blocks), and use
is_none() instead of !is_some() for OAuth2 flow checks
---
crates/specforge-core/src/diff.rs | 19 +++-
crates/specforge-core/src/security.rs | 121 ++++++++++++++++++++++----
2 files changed, 121 insertions(+), 19 deletions(-)
diff --git a/crates/specforge-core/src/diff.rs b/crates/specforge-core/src/diff.rs
index dd2afd4..a14f49f 100644
--- a/crates/specforge-core/src/diff.rs
+++ b/crates/specforge-core/src/diff.rs
@@ -5,7 +5,14 @@ use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum DiffSeverity { Breaking, Info }
-impl std::fmt::Display for DiffSeverity { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DiffSeverity::Breaking => write!(f, "breaking"), DiffSeverity::Info => write!(f, "info") } } }
+impl std::fmt::Display for DiffSeverity {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ DiffSeverity::Breaking => write!(f, "breaking"),
+ DiffSeverity::Info => write!(f, "info"),
+ }
+ }
+}
#[derive(Debug, Clone, Serialize)]
pub struct DiffFinding { pub severity: DiffSeverity, pub message: String, pub path: String }
@@ -40,7 +47,15 @@ impl std::fmt::Display for PropertyChange {
pub enum PropertyChangeKind { Added { ty: String }, AddedRequired { ty: String }, Removed { ty: String }, TypeChanged { old_type: String, new_type: String }, RequiredChanged { old_required: bool, new_required: bool } }
#[derive(Debug, Clone, Serialize)]
pub struct SchemaDiffDetail { pub name: String, pub changes: Vec }
-impl std::fmt::Display for SchemaDiffDetail { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { writeln!(f, "Schema {}:", self.name)?; for c in &self.changes { writeln!(f, " {c}")?; } Ok(()) } }
+impl std::fmt::Display for SchemaDiffDetail {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ writeln!(f, "Schema {}:", self.name)?;
+ for c in &self.changes {
+ writeln!(f, " {c}")?;
+ }
+ Ok(())
+ }
+}
#[derive(Debug, Clone, Serialize)]
pub struct DiffResult { pub findings: Vec, pub schema_diffs: Vec }
#[derive(Debug, Clone, Serialize)]
diff --git a/crates/specforge-core/src/security.rs b/crates/specforge-core/src/security.rs
index 2a13269..96cc58e 100644
--- a/crates/specforge-core/src/security.rs
+++ b/crates/specforge-core/src/security.rs
@@ -100,24 +100,111 @@ fn build_op_security_map(spec: &OpenAPI) -> BTreeMap) {
- if schemes.is_empty() { issues.push(SecurityIssue { severity: "warning".into(), message: "No security schemes defined in the specification".into(), path: "/".into() }); }
- for op in operations { if matches!(op.method.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") && !op.requires_auth {
- issues.push(SecurityIssue { severity: "warning".into(), message: format!("Write operation {} {} does not require authentication", op.method, op.path), path: op.path.clone() }); } }
- let auth: Vec
@@ -85,7 +85,7 @@ Each generated SDK is a standalone project — no shared runtime dependency, no
**5. Full IDE integration**
-The [VS Code extension](vscode-extension/) provides 23 commands, auto-validation on save, context menus, keyboard shortcuts, and a status bar — everything you need to work with OpenAPI specs without leaving your editor.
+The [VS Code extension](vscode-extension/) provides 24 commands, auto-validation on save, context menus, keyboard shortcuts, and a status bar — everything you need to work with OpenAPI specs without leaving your editor.
**6. Extensible via WASM plugins**
@@ -183,9 +183,9 @@ Every generated SDK is a **complete, production-ready client** — not just type
**WASM plugins**
- `specforge-plugin` crate with `Plugin` trait and `export_plugin!` macro
- Receives full IR as JSON, returns generated files
-- Compile to `wasm32-wasi` for any language emitter
+- Compile to `wasm32-wasip1` for any language emitter
-### CLI subcommands (22 commands)
+### CLI subcommands
| Command | What it does |
|---|---|
@@ -202,9 +202,8 @@ Every generated SDK is a **complete, production-ready client** — not just type
| `specforge versions` | List API versions in a spec directory |
| `specforge workspace` | Generate SDKs for all specs in a workspace config |
| `specforge workspace-init` | Generate a workspace config from a directory |
-| `specforge dashboard` | Generate HTML metrics dashboard from SDK telemetry |
-| `specforge security` | Analyze auth requirements and detect issues |
-| `specforge graph` | Generate schema dependency diagrams (Mermaid/DOT) |
+| `specforge profile` | Profile the performance of API endpoints from an OpenAPI spec |
+| `specforge plugin` | Manage WASM emitter plugins |
| `specforge analyze` | Detect unused schemas, duplicates, optimization opportunities |
| `specforge mock` | Start a local mock server from spec examples |
| `specforge export` | Export Swagger Editor-compatible spec (inline `$ref`) |
@@ -219,11 +218,13 @@ Every generated SDK is a **complete, production-ready client** — not just type
| `specforge market add` | Add a spec to the marketplace |
| `specforge changelog` | Auto-generate CHANGELOG from spec changes |
+> Run `specforge help` to see the full, current list — the CLI is the source of truth.
+
### Quality gates
-- **227 unit tests** across core, emitters, and CLI
+- **259 unit tests** across core, emitters, and CLI
- **21 test fixtures** (0.02MB to 14MB): petstore, GitHub, Stripe, Kubernetes, Atlassian, OpenAI, Vercel, Linode, Bitbucket, Adyen, Notion, Spotify, Adobe AEM, CircleCI, Okta, and more
-- **Compile gates**: generated Go must `go build`, generated Rust must `cargo check`
+- **Compile gates**: every emitter has a test that regenerates the SDK into a temp crate and compiles it — Go (`go build`), Rust (`cargo check`), TypeScript (`tsc --noEmit`). On top of that, `regression.rs` generates + compiles SDKs from real-world specs (GitHub ~9MB, Stripe ~7.6MB) in all three languages.
- **E2E smoke**: mock server × list/show/create + auth/retry/pagination (all 3 langs)
- **E2E advanced**: concurrency serialisation, dedupe single-flight, middleware rewrite, idempotency-key on POST, SSE parse (all 3 langs)
- **Performance benchmarks**: criterion benchmarks + 13 perf tests (petstore < 100ms, GitHub/Stripe < 10s)
@@ -632,7 +633,7 @@ sdk-ts/
├── README.md
└── src/
├── index.ts # createClient() + re-exports
- ├── client.ts # fetch core
+ ├── client.ts # fetch core (ApiClient + retry/dedupe/telemetry)
├── auth.ts
├── retry.ts
├── paginate.ts
@@ -641,6 +642,14 @@ sdk-ts/
├── middleware.ts
├── idempotency.ts # Idempotency-Key generation
├── streaming.ts # streamBytes / streamLines / streamSse
+ ├── cache.ts # ETag/conditional GET response cache
+ ├── ratelimit.ts # token-bucket + sliding-window rate limiters
+ ├── telemetry.ts # TelemetryHooks interface + MetricsCollector
+ ├── logging.ts # pluggable Logger (ConsoleLogger)
+ ├── interceptors.ts # request/response body interceptors
+ ├── validate.ts # generated per-model validators
+ ├── validation-middleware.ts # schema validation as middleware
+ ├── service_container.ts # DI grouping (fetch/cache/logger/telemetry)
├── errors.ts # discriminated-union ApiError
├── models/.ts # one file per schema (+ oneOf guards)
└── api/.ts # one class per tag
@@ -713,7 +722,7 @@ plugin/
└── README.md
```
-Build: `cargo build --target wasm32-wasi --release`
+Build: `cargo build --target wasm32-wasip1 --release`
@@ -1046,7 +1055,7 @@ Commands: `generate`, `check`, `diff`, `emit`
The [specforge VS Code extension](vscode-extension/) provides a full IDE experience for working with OpenAPI specs:
-**23 commands** accessible via `Ctrl+Shift+P`:
+**24 commands** accessible via `Ctrl+Shift+P`:
- **Generate SDK** — Pick language, generate to output directory
- **Check Spec** — Validate with `--strict` mode
- **Diff Spec** — Compare two spec versions