mirror of
https://github.com/linabutler/ploidy
synced 2026-07-14 18:45:32 +00:00
feat(codegen): Emit opt-in tracing spans, propagate trace context.
Generated clients now support `tracing` and `trace-context` features, both off by default. Enabling `tracing` adds an `#[instrument]` span to every operation method, and includes OTel HTTP semantic conventions fields plus path parameters in each span's attributes. `trace-context` enables `tracing` and injects trace context headers into each request. Rework `ploidy_util::Error` to use semantic error categories. Generated clients re-export this type, so this is a breaking change to generated code.
This commit is contained in:
Generated
+108
@@ -112,6 +112,17 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -917,6 +928,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
@@ -1085,6 +1102,31 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"js-sys",
|
||||
"pin-project-lite",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-http"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"http",
|
||||
"opentelemetry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "4.3.0"
|
||||
@@ -1223,6 +1265,8 @@ dependencies = [
|
||||
"chrono",
|
||||
"http",
|
||||
"itertools",
|
||||
"opentelemetry",
|
||||
"opentelemetry-http",
|
||||
"percent-encoding",
|
||||
"ploidy-pointer",
|
||||
"reqwest",
|
||||
@@ -1231,6 +1275,8 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_path_to_error",
|
||||
"thiserror",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
"url",
|
||||
"uuid",
|
||||
]
|
||||
@@ -1736,6 +1782,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -1910,6 +1965,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.3"
|
||||
@@ -2063,9 +2127,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
@@ -2073,6 +2149,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-opentelemetry"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adbc64cba7137545b8044cb1fe9814f7aacf3c6b5f9b45be8bb5db538befdb26"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"opentelemetry",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-subscriber",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"sharded-slab",
|
||||
"thread_local",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2160,6 +2262,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
||||
@@ -20,6 +20,10 @@ rust-version = "1.89"
|
||||
[workspace.dependencies]
|
||||
either = "1"
|
||||
indoc = "2"
|
||||
opentelemetry = { version = "0.32", default-features = false, features = [
|
||||
"trace",
|
||||
] }
|
||||
opentelemetry-http = { version = "0.32", default-features = false }
|
||||
ploidy-codegen-rust = { path = "ploidy-codegen-rust", version = "0.14.2" }
|
||||
ploidy-core = { path = "ploidy-core", version = "0.14.2" }
|
||||
ploidy-pointer = { path = "ploidy-pointer", version = "0.14.2" }
|
||||
@@ -30,6 +34,8 @@ rustc-hash = "2"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
toml_edit = { version = "0.25", features = ["serde"] }
|
||||
tracing = "0.1"
|
||||
tracing-opentelemetry = { version = "0.33", default-features = false }
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
@@ -354,7 +354,7 @@ Given a spec with an operation like:
|
||||
$ref: "#/components/schemas/User"
|
||||
```
|
||||
|
||||
...you can use the generated client to call that operation like:
|
||||
...you can use the client to call that operation like:
|
||||
|
||||
```rust
|
||||
use my_api_client::{Client, Error};
|
||||
@@ -373,12 +373,16 @@ async fn main() -> Result<(), Error> {
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> `with_user_agent`, `with_header`, and `with_sensitive_header` all set default headers for each request. Sensitive headers are excluded from debug output.
|
||||
The client uses [Reqwest](https://docs.rs/reqwest) under the hood, and has several convenience methods:
|
||||
|
||||
The generated client uses [Reqwest](https://docs.rs/reqwest) under the hood. If you need to configure connection options, like proxies, timeouts, or TLS, build your own `reqwest::Client` and pass it to `Client::with_reqwest_client`.
|
||||
* `with_user_agent()`, `with_header()`, and `with_sensitive_header()` set default headers for all requests. Sensitive headers are excluded from debug output.
|
||||
* `request()` returns a raw [`RequestBuilder`](https://docs.rs/reqwest/latest/reqwest/struct.RequestBuilder.html) with the client's base URL and default headers already applied. Use this for requests that the operation methods don't cover.
|
||||
* `Client::with_reqwest_client()` creates a client with a custom `reqwest::Client`. Use this to configure connection options like proxies, timeouts, and TLS.
|
||||
|
||||
For requests that the typed methods don't cover, `Client::request` returns a raw `RequestBuilder` with the client's base URL and default headers already applied.
|
||||
Generated clients also have two opt-in observability features:
|
||||
|
||||
* `tracing` instruments each operation method with a [`tracing` span](https://docs.rs/tracing/latest/tracing/span/index.html).
|
||||
* `trace-context` enables `tracing` and propagates trace context using your application's [OpenTelemetry propagator](https://docs.rs/opentelemetry/latest/opentelemetry/global/fn.get_text_map_propagator.html).
|
||||
|
||||
### Smart boxing
|
||||
|
||||
@@ -493,13 +497,15 @@ pub struct Customer {
|
||||
}
|
||||
```
|
||||
|
||||
All features are enabled by default, so the generated crate works out of the box. To enable just a subset of the generated features:
|
||||
All resource features are enabled by default, so the generated crate works out of the box. To enable just a subset of the generated resources:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
my-api-client = { version = "1", default-features = false, features = ["orders"] }
|
||||
```
|
||||
|
||||
The observability features are not enabled by default.
|
||||
|
||||
### Choosing the right tool
|
||||
|
||||
Ploidy focuses on generating Rust clients from modern OpenAPI specs. The broader ecosystem has strong options for other needs:
|
||||
@@ -540,7 +546,7 @@ Ploidy is opinionated by design. We'd rather get the defaults right than expose
|
||||
| Query parameters | Supported | `{OperationId}Query` struct argument |
|
||||
| Query `style` | Supported | `form`, `spaceDelimited`, `pipeDelimited`, `deepObject` |
|
||||
| Header and cookie parameters | Unsupported | - |
|
||||
| Request bodies | Partial | `application/json` and `*/*` schemas become typed arguments; `multipart/form-data` becomes `reqwest::multipart::Form` |
|
||||
| Request bodies | Partial | `application/json` and `*/*` schemas become typed model arguments; `multipart/form-data` becomes `reqwest::multipart::Form` |
|
||||
| Responses | Partial | The first `application/json` or `*/*` schema from either the lowest 2xx response or `default` becomes the return value; other response schemas are ignored |
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -69,9 +69,9 @@ impl<'a> CodegenCargoManifest<'a> {
|
||||
}
|
||||
|
||||
// Build the `features` section of the manifest.
|
||||
let mut features: BTreeMap<_, _> = deps_by_resource
|
||||
.iter()
|
||||
.map(|(resource, deps)| {
|
||||
let mut features = BTreeMap::new();
|
||||
if !deps_by_resource.is_empty() {
|
||||
features.extend(deps_by_resource.iter().map(|(resource, deps)| {
|
||||
(
|
||||
AsFeatureName(*resource).to_string(),
|
||||
FeatureDependencies(
|
||||
@@ -80,12 +80,8 @@ impl<'a> CodegenCargoManifest<'a> {
|
||||
.collect_vec(),
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if features.is_empty() {
|
||||
BTreeMap::new()
|
||||
} else {
|
||||
// `default` enables all other features.
|
||||
}));
|
||||
// `default` enables all resource features.
|
||||
features.insert(
|
||||
"default".to_owned(),
|
||||
FeatureDependencies(
|
||||
@@ -95,8 +91,21 @@ impl<'a> CodegenCargoManifest<'a> {
|
||||
.collect_vec(),
|
||||
),
|
||||
);
|
||||
features
|
||||
}
|
||||
// `tracing` enables per-method spans; `trace-context` adds
|
||||
// trace context propagation. Both are opt-in.
|
||||
features.insert(
|
||||
"tracing".to_owned(),
|
||||
FeatureDependencies(vec!["ploidy-util/tracing".to_owned()]),
|
||||
);
|
||||
features.insert(
|
||||
"trace-context".to_owned(),
|
||||
FeatureDependencies(vec![
|
||||
"tracing".to_owned(),
|
||||
"ploidy-util/trace-context".to_owned(),
|
||||
]),
|
||||
);
|
||||
features
|
||||
};
|
||||
|
||||
self.manifest.clone().apply(CargoManifestDiff {
|
||||
@@ -611,6 +620,10 @@ mod tests {
|
||||
[dependencies]
|
||||
serde = "1.0.0"
|
||||
ploidy-util = "{PLOIDY_VERSION}"
|
||||
|
||||
[features]
|
||||
trace-context = ["tracing", "ploidy-util/trace-context"]
|
||||
tracing = ["ploidy-util/tracing"]
|
||||
"#},
|
||||
);
|
||||
}
|
||||
@@ -713,7 +726,7 @@ mod tests {
|
||||
|
||||
let features = manifest.features();
|
||||
let keys = features.keys().copied().collect_vec();
|
||||
assert_matches!(&*keys, ["customer", "default"]);
|
||||
assert_matches!(&*keys, ["customer", "default", "trace-context", "tracing"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -741,7 +754,7 @@ mod tests {
|
||||
|
||||
let features = manifest.features();
|
||||
let keys = features.keys().copied().collect_vec();
|
||||
assert_matches!(&*keys, ["default", "pets"]);
|
||||
assert_matches!(&*keys, ["default", "pets", "trace-context", "tracing"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -777,12 +790,53 @@ mod tests {
|
||||
|
||||
let features = manifest.features();
|
||||
let keys = features.keys().copied().collect_vec();
|
||||
assert_matches!(&*keys, ["default", "oauth-2-token-2", "oauth2-token"]);
|
||||
assert_matches!(
|
||||
&*keys,
|
||||
[
|
||||
"default",
|
||||
"oauth-2-token-2",
|
||||
"oauth2-token",
|
||||
"trace-context",
|
||||
"tracing"
|
||||
]
|
||||
);
|
||||
assert_eq!(features["default"], ["oauth-2-token-2", "oauth2-token"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unnamed_schema_creates_no_features() {
|
||||
fn test_resource_named_tracing_does_not_collide_with_tracing_feature() {
|
||||
let doc = Document::from_yaml(indoc::indoc! {"
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Test
|
||||
version: 1.0.0
|
||||
components:
|
||||
schemas:
|
||||
Trace:
|
||||
type: object
|
||||
x-resourceId: tracing
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
"})
|
||||
.unwrap();
|
||||
|
||||
let arena = Arena::new();
|
||||
let spec = Spec::from_doc(&arena, &doc).unwrap();
|
||||
let graph = CodegenGraph::new(RawGraph::new(&arena, &spec).cook());
|
||||
let manifest = CodegenCargoManifest::new(&graph, &default_manifest()).to_manifest();
|
||||
|
||||
// `tracing` is reserved for the tracing feature, so the
|
||||
// resource's feature is uniquified to `tracing-2`.
|
||||
let features = manifest.features();
|
||||
let keys = features.keys().copied().collect_vec();
|
||||
assert_matches!(&*keys, ["default", "trace-context", "tracing", "tracing-2"]);
|
||||
assert_eq!(features["default"], ["tracing-2"]);
|
||||
assert_eq!(features["tracing"], ["ploidy-util/tracing"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unnamed_schema_creates_no_resource_features() {
|
||||
let doc = Document::from_yaml(indoc::indoc! {"
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
@@ -805,7 +859,7 @@ mod tests {
|
||||
|
||||
let features = manifest.features();
|
||||
let keys = features.keys().copied().collect_vec();
|
||||
assert_matches!(&*keys, []);
|
||||
assert_matches!(&*keys, ["trace-context", "tracing"]);
|
||||
}
|
||||
|
||||
// MARK: Schema feature dependencies
|
||||
|
||||
@@ -72,10 +72,10 @@ impl ToTokens for CodegenClientModule<'_> {
|
||||
{
|
||||
let name = name
|
||||
.try_into()
|
||||
.map_err(|err| crate::error::Error::BadHeaderName(err.into()))?;
|
||||
.map_err(crate::error::Error::bad_header_name)?;
|
||||
let value = value
|
||||
.try_into()
|
||||
.map_err(|err| crate::error::Error::BadHeaderValue(name.clone(), err.into()))?;
|
||||
.map_err(|err| crate::error::Error::bad_header_value(name.clone(), err))?;
|
||||
self.headers.insert(name, value);
|
||||
Ok(Self {
|
||||
client: self.client,
|
||||
@@ -105,10 +105,10 @@ impl ToTokens for CodegenClientModule<'_> {
|
||||
{
|
||||
let name = name
|
||||
.try_into()
|
||||
.map_err(|err| crate::error::Error::BadHeaderName(err.into()))?;
|
||||
.map_err(crate::error::Error::bad_header_name)?;
|
||||
let mut value: ::ploidy_util::http::HeaderValue = value
|
||||
.try_into()
|
||||
.map_err(|err| crate::error::Error::BadHeaderValue(name.clone(), err.into()))?;
|
||||
.map_err(|err| crate::error::Error::bad_header_value(name.clone(), err))?;
|
||||
value.set_sensitive(true);
|
||||
self.with_header(name, value)
|
||||
}
|
||||
|
||||
@@ -146,8 +146,10 @@ fn ident_map<'a>(cooked: &CookedGraph<'a>) -> IdentMap<'a> {
|
||||
.filter_map(|op| op.resource())
|
||||
.chain(cooked.schemas().filter_map(|ty| ty.resource()))
|
||||
.collect();
|
||||
// Resources become feature names; `default` is a special feature name.
|
||||
let mut scope = UniqueIdents::with_reserved(cooked.arena(), &["default"]);
|
||||
// Resources become feature names; `default`, `tracing`, and
|
||||
// `trace-context` are special feature names.
|
||||
let mut scope =
|
||||
UniqueIdents::with_reserved(cooked.arena(), &["default", "tracing", "trace-context"]);
|
||||
resources
|
||||
.into_iter()
|
||||
.map(move |name| (IdentMapKey::Resource(name), scope.claim(name)))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -186,9 +186,27 @@ mod tests {
|
||||
let expected: syn::File = parse_quote! {
|
||||
impl crate::client::Client {
|
||||
#[doc = " GET /customers"]
|
||||
#[cfg_attr(
|
||||
feature = "tracing",
|
||||
::tracing::instrument(
|
||||
skip_all,
|
||||
fields(
|
||||
otel.name = "GET /customers",
|
||||
otel.kind = "client",
|
||||
url.template = "/customers",
|
||||
http.request.method = "GET",
|
||||
server.address,
|
||||
server.port,
|
||||
url.full,
|
||||
http.response.status_code,
|
||||
error.type
|
||||
)
|
||||
)
|
||||
)]
|
||||
pub async fn list_customers(
|
||||
&self,
|
||||
) -> Result<::std::vec::Vec<crate::types::Customer>, crate::error::Error> {
|
||||
let result: Result<_, crate::error::Error> = async move {
|
||||
let url = {
|
||||
let mut url = self.base_url.clone();
|
||||
let _ = url
|
||||
@@ -199,19 +217,49 @@ mod tests {
|
||||
});
|
||||
url
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone())
|
||||
#[cfg(feature = "tracing")]
|
||||
{
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
server.address = url.host_str(),
|
||||
server.port = url.port_or_known_default(),
|
||||
url.full = url.as_str(),
|
||||
);
|
||||
}
|
||||
let request = {
|
||||
let request = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone());
|
||||
#[cfg(feature = "trace-context")]
|
||||
let request = ::ploidy_util::trace::propagate(
|
||||
::tracing::Span::current(),
|
||||
request,
|
||||
);
|
||||
request
|
||||
};
|
||||
let response = request
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
.await?;
|
||||
#[cfg(feature = "tracing")]
|
||||
{
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
http.response.status_code = response.status().as_u16()
|
||||
);
|
||||
}
|
||||
let response = response.error_for_status()?;
|
||||
let body = response.bytes().await?;
|
||||
let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
|
||||
let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
|
||||
.map_err(crate::error::JsonError::from)?;
|
||||
let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)?;
|
||||
Ok(result)
|
||||
}.await;
|
||||
#[cfg(feature = "tracing")]
|
||||
if let Err(err) = &result {
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
error.type = %err.category(),
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
};
|
||||
assert_eq!(actual, expected);
|
||||
@@ -272,9 +320,27 @@ mod tests {
|
||||
impl crate::client::Client {
|
||||
#[cfg(feature = "customer")]
|
||||
#[doc = " GET /orders"]
|
||||
#[cfg_attr(
|
||||
feature = "tracing",
|
||||
::tracing::instrument(
|
||||
skip_all,
|
||||
fields(
|
||||
otel.name = "GET /orders",
|
||||
otel.kind = "client",
|
||||
url.template = "/orders",
|
||||
http.request.method = "GET",
|
||||
server.address,
|
||||
server.port,
|
||||
url.full,
|
||||
http.response.status_code,
|
||||
error.type
|
||||
)
|
||||
)
|
||||
)]
|
||||
pub async fn list_orders(
|
||||
&self,
|
||||
) -> Result<::std::vec::Vec<crate::types::Order>, crate::error::Error> {
|
||||
let result: Result<_, crate::error::Error> = async move {
|
||||
let url = {
|
||||
let mut url = self.base_url.clone();
|
||||
let _ = url
|
||||
@@ -285,19 +351,49 @@ mod tests {
|
||||
});
|
||||
url
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone())
|
||||
#[cfg(feature = "tracing")]
|
||||
{
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
server.address = url.host_str(),
|
||||
server.port = url.port_or_known_default(),
|
||||
url.full = url.as_str(),
|
||||
);
|
||||
}
|
||||
let request = {
|
||||
let request = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone());
|
||||
#[cfg(feature = "trace-context")]
|
||||
let request = ::ploidy_util::trace::propagate(
|
||||
::tracing::Span::current(),
|
||||
request,
|
||||
);
|
||||
request
|
||||
};
|
||||
let response = request
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
.await?;
|
||||
#[cfg(feature = "tracing")]
|
||||
{
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
http.response.status_code = response.status().as_u16()
|
||||
);
|
||||
}
|
||||
let response = response.error_for_status()?;
|
||||
let body = response.bytes().await?;
|
||||
let deserializer = &mut ::ploidy_util::serde_json::Deserializer::from_slice(&body);
|
||||
let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)
|
||||
.map_err(crate::error::JsonError::from)?;
|
||||
let result = ::ploidy_util::serde_path_to_error::deserialize(deserializer)?;
|
||||
Ok(result)
|
||||
}.await;
|
||||
#[cfg(feature = "tracing")]
|
||||
if let Err(err) = &result {
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
error.type = %err.category(),
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
};
|
||||
assert_eq!(actual, expected);
|
||||
@@ -344,10 +440,28 @@ mod tests {
|
||||
let expected: syn::File = parse_quote! {
|
||||
impl crate::client::Client {
|
||||
#[doc = " GET /customers"]
|
||||
#[cfg_attr(
|
||||
feature = "tracing",
|
||||
::tracing::instrument(
|
||||
skip_all,
|
||||
fields(
|
||||
otel.name = "GET /customers",
|
||||
otel.kind = "client",
|
||||
url.template = "/customers",
|
||||
http.request.method = "GET",
|
||||
server.address,
|
||||
server.port,
|
||||
url.full,
|
||||
http.response.status_code,
|
||||
error.type
|
||||
)
|
||||
)
|
||||
)]
|
||||
pub async fn list_customers(
|
||||
&self,
|
||||
query: ¶meters::ListCustomersQuery
|
||||
) -> Result<(), crate::error::Error> {
|
||||
let result: Result<_, crate::error::Error> = async move {
|
||||
let url = {
|
||||
let mut url = self.base_url.clone();
|
||||
let _ = url
|
||||
@@ -365,16 +479,47 @@ mod tests {
|
||||
parameters::ListCustomersQuery::STYLES,
|
||||
),
|
||||
)?;
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone())
|
||||
#[cfg(feature = "tracing")]
|
||||
{
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
server.address = url.host_str(),
|
||||
server.port = url.port_or_known_default(),
|
||||
url.full = url.as_str(),
|
||||
);
|
||||
}
|
||||
let request = {
|
||||
let request = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone());
|
||||
#[cfg(feature = "trace-context")]
|
||||
let request = ::ploidy_util::trace::propagate(
|
||||
::tracing::Span::current(),
|
||||
request,
|
||||
);
|
||||
request
|
||||
};
|
||||
let response = request
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
.await?;
|
||||
#[cfg(feature = "tracing")]
|
||||
{
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
http.response.status_code = response.status().as_u16()
|
||||
);
|
||||
}
|
||||
let response = response.error_for_status()?;
|
||||
let _ = response;
|
||||
Ok(())
|
||||
}.await;
|
||||
#[cfg(feature = "tracing")]
|
||||
if let Err(err) = &result {
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
error.type = %err.category(),
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
pub mod parameters {
|
||||
mod list_customers_query {
|
||||
@@ -447,10 +592,28 @@ mod tests {
|
||||
let expected: syn::File = parse_quote! {
|
||||
impl crate::client::Client {
|
||||
#[doc = " GET /customers"]
|
||||
#[cfg_attr(
|
||||
feature = "tracing",
|
||||
::tracing::instrument(
|
||||
skip_all,
|
||||
fields(
|
||||
otel.name = "GET /customers",
|
||||
otel.kind = "client",
|
||||
url.template = "/customers",
|
||||
http.request.method = "GET",
|
||||
server.address,
|
||||
server.port,
|
||||
url.full,
|
||||
http.response.status_code,
|
||||
error.type
|
||||
)
|
||||
)
|
||||
)]
|
||||
pub async fn list_customers(
|
||||
&self,
|
||||
query: ¶meters::ListCustomersQuery
|
||||
) -> Result<(), crate::error::Error> {
|
||||
let result: Result<_, crate::error::Error> = async move {
|
||||
let url = {
|
||||
let mut url = self.base_url.clone();
|
||||
let _ = url
|
||||
@@ -468,21 +631,70 @@ mod tests {
|
||||
parameters::ListCustomersQuery::STYLES,
|
||||
),
|
||||
)?;
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone())
|
||||
#[cfg(feature = "tracing")]
|
||||
{
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
server.address = url.host_str(),
|
||||
server.port = url.port_or_known_default(),
|
||||
url.full = url.as_str(),
|
||||
);
|
||||
}
|
||||
let request = {
|
||||
let request = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone());
|
||||
#[cfg(feature = "trace-context")]
|
||||
let request = ::ploidy_util::trace::propagate(
|
||||
::tracing::Span::current(),
|
||||
request,
|
||||
);
|
||||
request
|
||||
};
|
||||
let response = request
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
.await?;
|
||||
#[cfg(feature = "tracing")]
|
||||
{
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
http.response.status_code = response.status().as_u16()
|
||||
);
|
||||
}
|
||||
let response = response.error_for_status()?;
|
||||
let _ = response;
|
||||
Ok(())
|
||||
}.await;
|
||||
#[cfg(feature = "tracing")]
|
||||
if let Err(err) = &result {
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
error.type = %err.category(),
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
#[doc = " GET /customers/search"]
|
||||
#[cfg_attr(
|
||||
feature = "tracing",
|
||||
::tracing::instrument(
|
||||
skip_all,
|
||||
fields(
|
||||
otel.name = "GET /customers/search",
|
||||
otel.kind = "client",
|
||||
url.template = "/customers/search",
|
||||
http.request.method = "GET",
|
||||
server.address,
|
||||
server.port,
|
||||
url.full,
|
||||
http.response.status_code,
|
||||
error.type
|
||||
)
|
||||
)
|
||||
)]
|
||||
pub async fn search_customers(
|
||||
&self,
|
||||
query: ¶meters::SearchCustomersQuery
|
||||
) -> Result<(), crate::error::Error> {
|
||||
let result: Result<_, crate::error::Error> = async move {
|
||||
let url = {
|
||||
let mut url = self.base_url.clone();
|
||||
let _ = url
|
||||
@@ -500,16 +712,47 @@ mod tests {
|
||||
parameters::SearchCustomersQuery::STYLES,
|
||||
),
|
||||
)?;
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone())
|
||||
#[cfg(feature = "tracing")]
|
||||
{
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
server.address = url.host_str(),
|
||||
server.port = url.port_or_known_default(),
|
||||
url.full = url.as_str(),
|
||||
);
|
||||
}
|
||||
let request = {
|
||||
let request = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone());
|
||||
#[cfg(feature = "trace-context")]
|
||||
let request = ::ploidy_util::trace::propagate(
|
||||
::tracing::Span::current(),
|
||||
request,
|
||||
);
|
||||
request
|
||||
};
|
||||
let response = request
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
.await?;
|
||||
#[cfg(feature = "tracing")]
|
||||
{
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
http.response.status_code = response.status().as_u16()
|
||||
);
|
||||
}
|
||||
let response = response.error_for_status()?;
|
||||
let _ = response;
|
||||
Ok(())
|
||||
}.await;
|
||||
#[cfg(feature = "tracing")]
|
||||
if let Err(err) = &result {
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
error.type = %err.category(),
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
pub mod parameters {
|
||||
mod list_customers_query {
|
||||
@@ -573,9 +816,27 @@ mod tests {
|
||||
let expected: syn::File = parse_quote! {
|
||||
impl crate::client::Client {
|
||||
#[doc = " GET /customers"]
|
||||
#[cfg_attr(
|
||||
feature = "tracing",
|
||||
::tracing::instrument(
|
||||
skip_all,
|
||||
fields(
|
||||
otel.name = "GET /customers",
|
||||
otel.kind = "client",
|
||||
url.template = "/customers",
|
||||
http.request.method = "GET",
|
||||
server.address,
|
||||
server.port,
|
||||
url.full,
|
||||
http.response.status_code,
|
||||
error.type
|
||||
)
|
||||
)
|
||||
)]
|
||||
pub async fn list_customers(
|
||||
&self,
|
||||
) -> Result<(), crate::error::Error> {
|
||||
let result: Result<_, crate::error::Error> = async move {
|
||||
let url = {
|
||||
let mut url = self.base_url.clone();
|
||||
let _ = url
|
||||
@@ -586,16 +847,47 @@ mod tests {
|
||||
});
|
||||
url
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone())
|
||||
#[cfg(feature = "tracing")]
|
||||
{
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
server.address = url.host_str(),
|
||||
server.port = url.port_or_known_default(),
|
||||
url.full = url.as_str(),
|
||||
);
|
||||
}
|
||||
let request = {
|
||||
let request = self
|
||||
.client
|
||||
.get(url)
|
||||
.headers(self.headers.clone());
|
||||
#[cfg(feature = "trace-context")]
|
||||
let request = ::ploidy_util::trace::propagate(
|
||||
::tracing::Span::current(),
|
||||
request,
|
||||
);
|
||||
request
|
||||
};
|
||||
let response = request
|
||||
.send()
|
||||
.await?
|
||||
.error_for_status()?;
|
||||
.await?;
|
||||
#[cfg(feature = "tracing")]
|
||||
{
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
http.response.status_code = response.status().as_u16()
|
||||
);
|
||||
}
|
||||
let response = response.error_for_status()?;
|
||||
let _ = response;
|
||||
Ok(())
|
||||
}.await;
|
||||
#[cfg(feature = "tracing")]
|
||||
if let Err(err) = &result {
|
||||
::tracing::record_all!(::tracing::Span::current(),
|
||||
error.type = %err.category(),
|
||||
);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
};
|
||||
assert_eq!(actual, expected);
|
||||
|
||||
@@ -12,6 +12,12 @@ impl ToTokens for CodegenLibrary {
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
|
||||
#[cfg(feature = "tracing")]
|
||||
extern crate self as tracing;
|
||||
|
||||
#[cfg(feature = "tracing")]
|
||||
pub(crate) use ::ploidy_util::tracing::*;
|
||||
|
||||
// Re-export `ploidy-util`, so that consumers don't need to
|
||||
// depend on it directly.
|
||||
pub use ::ploidy_util as util;
|
||||
|
||||
@@ -14,6 +14,8 @@ base64 = "0.22"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
http = "1"
|
||||
itertools = "0.14"
|
||||
opentelemetry = { workspace = true, optional = true }
|
||||
opentelemetry-http = { workspace = true, optional = true }
|
||||
percent-encoding = "2.3"
|
||||
ploidy-pointer = { workspace = true, features = [
|
||||
"chrono",
|
||||
@@ -37,8 +39,17 @@ serde_bytes = "0.11"
|
||||
serde_json = { workspace = true }
|
||||
serde_path_to_error = "0.1"
|
||||
thiserror = "2"
|
||||
tracing = { workspace = true, optional = true }
|
||||
tracing-opentelemetry = { workspace = true, optional = true }
|
||||
url = { version = "2", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["serde", "v4"] }
|
||||
|
||||
[features]
|
||||
did-you-mean = ["ploidy-pointer/did-you-mean"]
|
||||
tracing = ["dep:tracing"]
|
||||
trace-context = [
|
||||
"tracing",
|
||||
"dep:opentelemetry",
|
||||
"dep:opentelemetry-http",
|
||||
"dep:tracing-opentelemetry",
|
||||
]
|
||||
|
||||
+131
-29
@@ -1,41 +1,143 @@
|
||||
/// Transport-level error types.
|
||||
use std::fmt::{Display, Formatter, Result as FmtResult};
|
||||
|
||||
use http::{HeaderName, StatusCode, uri::InvalidUri};
|
||||
use url::ParseError as UrlParseError;
|
||||
|
||||
use crate::query::QueryParamError;
|
||||
|
||||
/// A client error.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// Network or connection error.
|
||||
#[error("Network error")]
|
||||
Network(#[from] reqwest::Error),
|
||||
#[error("error building request")]
|
||||
Build(#[from] BuildError),
|
||||
|
||||
/// Invalid JSON in request or response.
|
||||
#[error("Malformed JSON")]
|
||||
Json(#[from] JsonError),
|
||||
#[error("HTTP transport error")]
|
||||
Transport(#[source] reqwest::Error),
|
||||
|
||||
/// Invalid URL.
|
||||
#[error("Malformed URL")]
|
||||
Url(#[from] url::ParseError),
|
||||
#[error("HTTP status error ({0})")]
|
||||
Status(StatusCode),
|
||||
|
||||
/// Invalid query parameter.
|
||||
#[error("Invalid query parameter")]
|
||||
QueryParam(#[from] crate::QueryParamError),
|
||||
|
||||
/// Invalid request path.
|
||||
#[error("Invalid request path")]
|
||||
InvalidPath(#[from] crate::http::uri::InvalidUri),
|
||||
|
||||
/// Invalid HTTP header name.
|
||||
#[error("Invalid header name")]
|
||||
BadHeaderName(#[source] http::Error),
|
||||
|
||||
/// Invalid HTTP header value.
|
||||
#[error("Invalid value for header `{0}`")]
|
||||
BadHeaderValue(http::HeaderName, #[source] http::Error),
|
||||
#[error("invalid or unexpected response body")]
|
||||
Body(#[from] BodyError),
|
||||
}
|
||||
|
||||
/// Invalid or unexpected JSON, with or without a path
|
||||
impl Error {
|
||||
/// Creates an error for an invalid HTTP header name.
|
||||
#[cold]
|
||||
pub fn bad_header_name(err: impl Into<http::Error>) -> Self {
|
||||
Self::Build(BuildError::HeaderName(err.into()))
|
||||
}
|
||||
|
||||
/// Creates an error for an invalid HTTP header value.
|
||||
#[cold]
|
||||
pub fn bad_header_value(name: HeaderName, err: impl Into<http::Error>) -> Self {
|
||||
Self::Build(BuildError::HeaderValue(name, err.into()))
|
||||
}
|
||||
|
||||
/// Returns the telemetry category for this error.
|
||||
pub fn category(&self) -> ErrorCategory {
|
||||
match self {
|
||||
Self::Build(_) => ErrorCategory::Build,
|
||||
Self::Transport(err) if err.is_timeout() => ErrorCategory::Timeout,
|
||||
Self::Transport(err) if err.is_connect() => ErrorCategory::Connect,
|
||||
Self::Transport(_) => ErrorCategory::Transport,
|
||||
&Self::Status(status) => ErrorCategory::Status(status),
|
||||
Self::Body(_) => ErrorCategory::Body,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<InvalidUri> for Error {
|
||||
fn from(err: InvalidUri) -> Self {
|
||||
Self::Build(BuildError::Path(err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<QueryParamError> for Error {
|
||||
fn from(err: QueryParamError) -> Self {
|
||||
Self::Build(BuildError::QueryParam(err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UrlParseError> for Error {
|
||||
fn from(err: UrlParseError) -> Self {
|
||||
Self::Build(BuildError::Url(err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for Error {
|
||||
#[cold]
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
if err.is_builder() {
|
||||
Self::Build(BuildError::Request(err))
|
||||
} else if let Some(status) = err.status() {
|
||||
Self::Status(status)
|
||||
} else {
|
||||
Self::Transport(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
#[cold]
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
Self::Body(BodyError::Json(err))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_path_to_error::Error<serde_json::Error>> for Error {
|
||||
#[cold]
|
||||
fn from(err: serde_path_to_error::Error<serde_json::Error>) -> Self {
|
||||
Self::Body(BodyError::JsonWithPath(err))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BuildError {
|
||||
#[error("invalid URL")]
|
||||
Url(#[source] UrlParseError),
|
||||
#[error("invalid query parameter")]
|
||||
QueryParam(#[source] QueryParamError),
|
||||
#[error("invalid request path")]
|
||||
Path(#[source] InvalidUri),
|
||||
#[error("invalid header name")]
|
||||
HeaderName(#[source] http::Error),
|
||||
#[error("invalid value for header `{0}`")]
|
||||
HeaderValue(HeaderName, #[source] http::Error),
|
||||
#[error(transparent)]
|
||||
Request(reqwest::Error),
|
||||
}
|
||||
|
||||
/// Invalid or unexpected response body, with or without a path
|
||||
/// to the unexpected section.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum JsonError {
|
||||
pub enum BodyError {
|
||||
#[error(transparent)]
|
||||
Json(#[from] serde_json::Error),
|
||||
Json(serde_json::Error),
|
||||
#[error(transparent)]
|
||||
JsonWithPath(#[from] serde_path_to_error::Error<serde_json::Error>),
|
||||
JsonWithPath(serde_path_to_error::Error<serde_json::Error>),
|
||||
}
|
||||
|
||||
/// The telemetry category for an [`Error`].
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ErrorCategory {
|
||||
Build,
|
||||
Connect,
|
||||
Timeout,
|
||||
Transport,
|
||||
Status(StatusCode),
|
||||
Body,
|
||||
}
|
||||
|
||||
impl Display for ErrorCategory {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
|
||||
f.write_str(match self {
|
||||
Self::Build => "build",
|
||||
Self::Connect => "connect",
|
||||
Self::Timeout => "timeout",
|
||||
Self::Transport => "transport",
|
||||
Self::Status(status) => status.as_str(),
|
||||
Self::Body => "body",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ pub mod binary;
|
||||
pub mod date_time;
|
||||
pub mod error;
|
||||
pub mod query;
|
||||
#[cfg(feature = "trace-context")]
|
||||
pub mod trace;
|
||||
|
||||
pub use absent::{AbsentError, AbsentOr, AbsentOrExt, FieldAbsentError};
|
||||
pub use binary::{Base64, Base64Error};
|
||||
@@ -20,5 +22,7 @@ pub use serde;
|
||||
pub use serde_bytes;
|
||||
pub use serde_json;
|
||||
pub use serde_path_to_error;
|
||||
#[cfg(feature = "tracing")]
|
||||
pub use tracing;
|
||||
pub use url;
|
||||
pub use uuid;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Tracing support for generated clients.
|
||||
|
||||
use http::HeaderMap;
|
||||
use opentelemetry::global::get_text_map_propagator;
|
||||
use opentelemetry_http::HeaderInjector;
|
||||
use reqwest::RequestBuilder;
|
||||
use tracing::Span;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
/// Adds trace context request headers, if there is one and
|
||||
/// a global [`TextMapPropagator`] is [set].
|
||||
///
|
||||
/// [`TextMapPropagator`]: opentelemetry::propagation::TextMapPropagator
|
||||
/// [set]: opentelemetry::global::set_text_map_propagator
|
||||
pub fn propagate(span: Span, request: RequestBuilder) -> RequestBuilder {
|
||||
let context = span.context();
|
||||
let mut headers = HeaderMap::new();
|
||||
get_text_map_propagator(|p| {
|
||||
p.inject_context(&context, &mut HeaderInjector(&mut headers));
|
||||
});
|
||||
// We intentionally use `request.headers()` to replace any
|
||||
// existing trace headers.
|
||||
request.headers(headers)
|
||||
}
|
||||
Reference in New Issue
Block a user