Compare commits

..
27 Commits
Author SHA1 Message Date
hiimjako 25b699da28 docs: remove challenge file
Rust / check (push) Successful in 32s
Rust / test (push) Successful in 32s
Docker / build (push) Successful in 37s
2026-06-16 14:21:31 +02:00
hiimjako 17dc59699e docs: improve docs
Docker / build (push) Successful in 22s
Rust / check (push) Successful in 32s
Rust / test (push) Successful in 33s
2026-06-16 14:11:25 +02:00
hiimjako f367692287 ci: avoid qemu on local runners
Rust / test (push) Successful in 1m2s
Rust / check (push) Successful in 1m23s
Docker / build (push) Successful in 2m4s
2026-06-16 13:59:34 +02:00
hiimjako fe6244f51c feat: add challenge file
Docker / build (push) Failing after 28s
Rust / test (push) Successful in 53s
Rust / check (push) Successful in 56s
2026-06-16 13:53:39 +02:00
hiimjako 0ffb1e8b5f chore: add make targets 2026-06-15 12:35:58 +02:00
hiimjako 23eaea36c1 fix: normalize pokemon lookup names 2026-06-15 12:26:26 +02:00
hiimjako cf11e20f90 feat: document api contract 2026-06-15 12:26:26 +02:00
hiimjako b910e77460 ci: add nightly external contract tests 2026-06-15 12:26:26 +02:00
hiimjako c35f8cce14 fix: use mercxry translations api 2026-06-15 12:26:26 +02:00
hiimjako d6fc3bdd4b feat: add api test cli 2026-06-15 12:26:26 +02:00
hiimjako ce959b59bf fix: cover api edge cases 2026-06-15 12:26:26 +02:00
hiimjako aa3b678cc5 refactor: introduce lightweight hexagonal layout 2026-06-15 12:26:26 +02:00
hiimjako 6fc5cc1bc8 refactor: move rate limiter into http layer 2026-06-15 12:26:26 +02:00
hiimjako 0f195de9e5 refactor: split telemetry module 2026-06-15 12:26:26 +02:00
hiimjako da910a88c7 refactor: split http layer 2026-06-15 12:26:26 +02:00
hiimjako a6dd497e8a test: move app tests to integration suite 2026-06-15 12:26:26 +02:00
hiimjako 653accc511 refactor: move entrypoint to bin 2026-06-15 12:26:26 +02:00
hiimjako 78ff3473ee fix: address review findings 2026-06-15 12:26:26 +02:00
hiimjako 66496ec476 docs: add usage guide 2026-06-15 12:26:26 +02:00
hiimjako c92714d13b ci: build docker image 2026-06-15 12:26:26 +02:00
hiimjako cde25f6dbb chore: add multi-arch docker build 2026-06-15 12:26:26 +02:00
hiimjako c64b738117 feat: add request rate limiting 2026-06-13 06:14:47 +02:00
hiimjako e7cf7729ee feat: add telemetry instrumentation 2026-06-13 06:12:51 +02:00
hiimjako 0a1c6ca7ab feat: expose pokemon api 2026-06-13 06:09:15 +02:00
hiimjako 5acf241033 feat: add translation service 2026-06-13 06:08:09 +02:00
hiimjako 3eb57cd327 feat: add pokeapi client 2026-06-13 06:07:02 +02:00
hiimjako b84de88585 chore: bootstrap rust service 2026-06-13 06:05:45 +02:00
10 changed files with 524 additions and 19 deletions
+3 -3
View File
@@ -74,11 +74,11 @@ COPY --from=builder /usr/local/bin/pokedex-api /usr/local/bin/pokedex-api
ARG GIT_SHA=unknown
ENV GIT_SHA=$GIT_SHA \
BIND_ADDR=0.0.0.0:8000
BIND_ADDR=0.0.0.0:5000
EXPOSE 8000
EXPOSE 5000
USER app
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -fsS http://127.0.0.1:8000/health || exit 1
CMD curl -fsS http://127.0.0.1:5000/health || exit 1
CMD ["pokedex-api"]
+16 -4
View File
@@ -4,18 +4,21 @@ CARGO ?= cargo
DOCKER ?= docker
IMAGE ?= pokedex-api
TAG ?= local
BASE_URL ?= http://localhost:8000
BASE_URL ?= http://localhost:5000
POKEMON ?= mewtwo
PLATFORMS ?= linux/amd64,linux/arm64
GIT_SHA ?= $(shell git rev-parse HEAD 2>/dev/null || printf unknown)
.PHONY: help run docker-build docker-run health pokemon translated metrics fmt clippy test check contract-test docker-buildx
.PHONY: help run docker-build docker-run cli-health cli-pokemon cli-translated health pokemon translated metrics fmt clippy test check contract-test docker-buildx
help:
@printf 'Available targets:\n'
@printf ' make run Run the API locally with cargo\n'
@printf ' make docker-build Build the Docker image\n'
@printf ' make docker-run Run the Docker image on port 8000\n'
@printf ' make docker-run Run the Docker image on port 5000\n'
@printf ' make cli-health Call /health through the CLI helper\n'
@printf ' make cli-pokemon Call /pokemon/$${POKEMON} through the CLI helper\n'
@printf ' make cli-translated Call /pokemon/translated/$${POKEMON} through the CLI helper\n'
@printf ' make health curl /health\n'
@printf ' make pokemon curl /pokemon/$${POKEMON}\n'
@printf ' make translated curl /pokemon/translated/$${POKEMON}\n'
@@ -31,7 +34,16 @@ docker-build:
$(DOCKER) build -t $(IMAGE) .
docker-run:
$(DOCKER) run --rm -p 8000:8000 $(IMAGE)
$(DOCKER) run --rm -p 5000:5000 $(IMAGE)
cli-health:
$(CARGO) run --bin pokedex-cli -- health
cli-pokemon:
$(CARGO) run --bin pokedex-cli -- --base-url $(BASE_URL) pokemon $(POKEMON)
cli-translated:
$(CARGO) run --bin pokedex-cli -- --base-url $(BASE_URL) translated $(POKEMON)
health:
curl -fsS $(BASE_URL)/health
+15 -9
View File
@@ -4,12 +4,6 @@ REST API for the TrueLayer Software Engineering Challenge. It returns basic Pok
## Requirements
Install these tools first:
- Rust, via `rustup`, for local development and tests.
- `make`, for the documented command shortcuts.
- Docker, for the containerized run path.
You can run it either with Docker or with a local Rust toolchain. Common commands are available through `make`; run `make help` for the full list.
### Option A: Docker
@@ -37,7 +31,18 @@ Then run the service:
make run
```
The API listens on `0.0.0.0:8000` by default.
The API listens on `0.0.0.0:5000` by default.
## CLI helper
With the API running in another terminal, call it through the small helper binary:
```bash
make cli-health
make cli-pokemon
make cli-translated
make cli-pokemon POKEMON=pikachu BASE_URL=http://localhost:5000
```
## Endpoints
@@ -69,6 +74,7 @@ This is a small Rust service, so the structure is intentionally a lightweight he
```mermaid
flowchart LR
CLI[CLI helper] --> HTTP
HTTP[HTTP layer: axum routes, handlers, middleware] --> Application
Application[Application service: use cases and translation rules] --> Domain
Application --> Clients
@@ -84,7 +90,7 @@ flowchart LR
- `clients`: adapters for external providers (`PokéAPI`, `FunTranslations`).
- `http`: API transport concerns, routing, handlers, request IDs, metrics middleware, and rate limiting.
- `telemetry`: OpenTelemetry metrics and tracing setup.
- `bin`: executable entrypoint for the API.
- `bin`: executable entrypoints for the API and the local CLI helper.
## Deliberate trade-offs
@@ -98,7 +104,7 @@ External contract tests are separated from the normal deterministic test suite.
| Variable | Default | Description |
| --------------------------- | ------------------------------------------- | -------------------------- |
| `BIND_ADDR` | `0.0.0.0:8000` | HTTP bind address |
| `BIND_ADDR` | `0.0.0.0:5000` | HTTP bind address |
| `POKEAPI_BASE_URL` | `https://pokeapi.co/api/v2` | PokéAPI base URL |
| `FUN_TRANSLATIONS_BASE_URL` | `https://api.funtranslations.mercxry.me/v1` | FunTranslations base URL |
| `REQUEST_TIMEOUT_SECONDS` | `5` | Upstream HTTP timeout |
+1 -1
View File
@@ -4,7 +4,7 @@ info:
version: 0.1.0
description: REST API for the TrueLayer Pokémon challenge.
servers:
- url: http://localhost:8000
- url: http://localhost:5000
paths:
/health:
get:
+141
View File
@@ -113,3 +113,144 @@ pub fn translation_kind_for(pokemon: &PokemonInfo) -> TranslationKind {
TranslationKind::Shakespeare
}
}
#[cfg(test)]
mod tests {
use super::{PokedexService, translation_kind_for};
use crate::{
clients::{funtranslations::TranslationClient, pokeapi::PokeApiClient},
domain::{PokemonInfo, TranslationKind},
telemetry::Telemetry,
};
use std::time::Duration;
use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
#[test]
fn chooses_yoda_for_legendary_pokemon() {
let pokemon = pokemon_info("rare", true, "Created by science.");
assert_eq!(translation_kind_for(&pokemon), TranslationKind::Yoda);
}
#[test]
fn chooses_yoda_for_cave_pokemon() {
let pokemon = pokemon_info("cave", false, "Sleeps upside down.");
assert_eq!(translation_kind_for(&pokemon), TranslationKind::Yoda);
}
#[test]
fn chooses_shakespeare_for_regular_pokemon() {
let pokemon = pokemon_info("forest", false, "Electric cheeks.");
assert_eq!(translation_kind_for(&pokemon), TranslationKind::Shakespeare);
}
#[tokio::test]
async fn translates_legendary_pokemon_with_yoda() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/yoda"))
.and(matchers::body_string_contains("Created by science"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"contents": { "translated": "Created by science, it was." }
})))
.expect(1)
.mount(&translations)
.await;
let service = service_for(&pokeapi, &translations);
let pokemon = service
.get_translated("mewtwo")
.await
.expect("translated pokemon should be returned");
assert_eq!(pokemon.description, "Created by science, it was.");
}
#[tokio::test]
async fn translates_regular_pokemon_with_shakespeare() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
mount_species(&pokeapi, "pikachu", false, "forest", "Electric cheeks.").await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/shakespeare"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"contents": { "translated": "Electric cheeks, good sir." }
})))
.expect(1)
.mount(&translations)
.await;
let service = service_for(&pokeapi, &translations);
let pokemon = service
.get_translated("pikachu")
.await
.expect("translated pokemon should be returned");
assert_eq!(pokemon.description, "Electric cheeks, good sir.");
}
#[tokio::test]
async fn falls_back_to_original_description_when_translation_fails() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
mount_species(&pokeapi, "zubat", false, "cave", "It has no eyes.").await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/translate/yoda"))
.respond_with(ResponseTemplate::new(500))
.expect(1)
.mount(&translations)
.await;
let service = service_for(&pokeapi, &translations);
let pokemon = service
.get_translated("zubat")
.await
.expect("translation failure should not fail the endpoint");
assert_eq!(pokemon.description, "It has no eyes.");
}
async fn mount_species(
server: &MockServer,
name: &str,
is_legendary: bool,
habitat: &str,
description: &str,
) {
Mock::given(matchers::method("GET"))
.and(matchers::path(format!("/pokemon-species/{name}")))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"name": name,
"is_legendary": is_legendary,
"habitat": { "name": habitat },
"flavor_text_entries": [
{ "flavor_text": description, "language": { "name": "en" } }
]
})))
.expect(1)
.mount(server)
.await;
}
fn pokemon_info(habitat: &str, is_legendary: bool, description: &str) -> PokemonInfo {
PokemonInfo {
name: "testmon".to_owned(),
description: description.to_owned(),
habitat: habitat.to_owned(),
is_legendary,
}
}
fn service_for(pokeapi: &MockServer, translations: &MockServer) -> PokedexService {
PokedexService::new(
PokeApiClient::new(pokeapi.uri(), Duration::from_secs(1)).expect("client should build"),
TranslationClient::new(translations.uri(), Duration::from_secs(1))
.expect("client should build"),
Telemetry::new("test-service").metrics(),
)
}
}
+275
View File
@@ -0,0 +1,275 @@
#![deny(warnings)]
#![deny(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)]
#![allow(clippy::missing_errors_doc, clippy::multiple_crate_versions)]
use std::{env, process::ExitCode, time::Duration};
const DEFAULT_BASE_URL: &str = "http://localhost:5000";
const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::main]
async fn main() -> ExitCode {
match run(env::args().skip(1)).await {
Ok(output) => {
println!("{output}");
ExitCode::SUCCESS
}
Err(CliError::Help) => {
eprintln!("{}", usage());
ExitCode::SUCCESS
}
Err(CliError::Usage(message)) => {
eprintln!("{message}\n\n{}", usage());
ExitCode::from(2)
}
Err(error) => {
eprintln!("error: {error}");
ExitCode::FAILURE
}
}
}
async fn run<I, S>(args: I) -> Result<String, CliError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let args = parse_args(args)?;
call_api(&args).await
}
async fn call_api(args: &CliArgs) -> Result<String, CliError> {
let client = reqwest::Client::builder()
.timeout(REQUEST_TIMEOUT)
.build()?;
let response = client.get(args.url()).send().await?;
let status = response.status();
let body = response.text().await?;
if status.is_success() {
pretty_body(&body)
} else {
Err(CliError::Api { status, body })
}
}
fn pretty_body(body: &str) -> Result<String, CliError> {
serde_json::from_str::<serde_json::Value>(body).map_or_else(
|_| Ok(body.to_owned()),
|value| serde_json::to_string_pretty(&value).map_err(CliError::from),
)
}
#[derive(Debug, Eq, PartialEq)]
struct CliArgs {
base_url: String,
command: Command,
}
impl CliArgs {
fn url(&self) -> String {
format!("{}{}", self.base_url, self.command.path())
}
}
#[derive(Debug, Eq, PartialEq)]
enum Command {
Health,
Metrics,
Pokemon { name: String },
TranslatedPokemon { name: String },
}
impl Command {
fn path(&self) -> String {
match self {
Self::Health => "/health".to_owned(),
Self::Metrics => "/metrics".to_owned(),
Self::Pokemon { name } => format!("/pokemon/{name}"),
Self::TranslatedPokemon { name } => format!("/pokemon/translated/{name}"),
}
}
}
fn parse_args<I, S>(args: I) -> Result<CliArgs, CliError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let mut base_url = DEFAULT_BASE_URL.to_owned();
let mut positional = Vec::new();
let mut iter = args.into_iter().map(Into::into);
while let Some(token) = iter.next() {
match token.as_str() {
"--help" | "-h" => return Err(CliError::Help),
"--base-url" | "-u" => {
base_url = iter
.next()
.ok_or_else(|| CliError::Usage("missing value for --base-url".to_owned()))?;
}
_ if token.starts_with('-') => {
return Err(CliError::Usage(format!("unknown option: {token}")));
}
_ => {
positional.push(token);
positional.extend(iter);
break;
}
}
}
Ok(CliArgs {
base_url: trim_base_url(&base_url),
command: parse_command(&positional)?,
})
}
fn parse_command(positional: &[String]) -> Result<Command, CliError> {
match positional {
[command] if command == "health" => Ok(Command::Health),
[command] if command == "metrics" => Ok(Command::Metrics),
[command, name] if command == "pokemon" => {
parse_pokemon_name(name).map(|name| Command::Pokemon {
name: name.to_owned(),
})
}
[command, name] if command == "translated" => {
parse_pokemon_name(name).map(|name| Command::TranslatedPokemon {
name: name.to_owned(),
})
}
[] => Err(CliError::Usage("missing command".to_owned())),
_ => Err(CliError::Usage("invalid command or arguments".to_owned())),
}
}
fn parse_pokemon_name(name: &str) -> Result<&str, CliError> {
if is_valid_pokemon_name(name) {
Ok(name)
} else {
Err(CliError::Usage(
"pokemon name must contain only ASCII letters, digits, or hyphens".to_owned(),
))
}
}
fn is_valid_pokemon_name(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
}
fn trim_base_url(base_url: &str) -> String {
base_url.trim_end_matches('/').to_owned()
}
const fn usage() -> &'static str {
"Usage:\n pokedex-cli [--base-url URL] health\n pokedex-cli [--base-url URL] metrics\n pokedex-cli [--base-url URL] pokemon <name>\n pokedex-cli [--base-url URL] translated <name>\n\nExamples:\n pokedex-cli pokemon mewtwo\n pokedex-cli translated mewtwo\n pokedex-cli --base-url http://localhost:5000 pokemon pikachu"
}
#[derive(Debug, thiserror::Error)]
enum CliError {
#[error("API returned {status}: {body}")]
Api {
status: reqwest::StatusCode,
body: String,
},
#[error(transparent)]
Http(#[from] reqwest::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error("{0}")]
Usage(String),
#[error("help requested")]
Help,
}
#[cfg(test)]
mod tests {
use super::{CliArgs, CliError, Command, DEFAULT_BASE_URL, parse_args, run};
use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
#[test]
fn parses_default_base_url_and_pokemon_command() {
let args = parse_args(["pokemon", "mewtwo"]).expect("args should parse");
assert_eq!(
args,
CliArgs {
base_url: DEFAULT_BASE_URL.to_owned(),
command: Command::Pokemon {
name: "mewtwo".to_owned()
}
}
);
assert_eq!(args.url(), "http://localhost:5000/pokemon/mewtwo");
}
#[test]
fn parses_custom_base_url_and_translated_command() {
let args = parse_args([
"--base-url",
"http://localhost:5000/",
"translated",
"mr-mime",
])
.expect("args should parse");
assert_eq!(args.base_url, "http://localhost:5000");
assert_eq!(
args.url(),
"http://localhost:5000/pokemon/translated/mr-mime"
);
}
#[test]
fn rejects_names_that_do_not_match_the_api_contract() {
let error = parse_args(["pokemon", "foo?bar=baz"]).expect_err("args should fail");
assert!(matches!(error, CliError::Usage(_)));
}
#[tokio::test]
async fn calls_api_and_pretty_prints_json() {
let server = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/pokemon/mewtwo"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"name": "mewtwo",
"isLegendary": true
})))
.expect(1)
.mount(&server)
.await;
let base_url = server.uri();
let output = run(["--base-url", &base_url, "pokemon", "mewtwo"])
.await
.expect("request should succeed");
assert!(output.contains("\"name\": \"mewtwo\""));
assert!(output.contains("\"isLegendary\": true"));
}
#[tokio::test]
async fn returns_api_errors_with_response_body() {
let server = MockServer::start().await;
Mock::given(matchers::method("GET"))
.and(matchers::path("/pokemon/nope"))
.respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
"error": "pokemon not found"
})))
.mount(&server)
.await;
let base_url = server.uri();
let error = run(["--base-url", &base_url, "pokemon", "nope"])
.await
.expect_err("request should fail");
assert!(error.to_string().contains("404"));
assert!(error.to_string().contains("pokemon not found"));
}
}
+2 -2
View File
@@ -66,7 +66,7 @@ fn env_or_string(name: &'static str, default: &str) -> String {
}
fn default_bind_addr() -> SocketAddr {
SocketAddr::from(([0, 0, 0, 0], 8000))
SocketAddr::from(([0, 0, 0, 0], 5000))
}
const fn validate_rate_limit(per_second: f64, burst: u32) -> Result<(), ConfigError> {
@@ -101,7 +101,7 @@ mod tests {
fn default_config_is_valid_for_local_development() {
let config = AppConfig::default_local();
assert_eq!(config.bind_addr.port(), 8000);
assert_eq!(config.bind_addr.port(), 5000);
assert_eq!(config.pokeapi_base_url, "https://pokeapi.co/api/v2");
assert!((config.rate_limit_per_second - 20.0).abs() < f64::EPSILON);
}
+13
View File
@@ -70,6 +70,7 @@ impl TokenBucket {
#[cfg(test)]
mod tests {
use super::RateLimiter;
use std::time::Duration;
#[test]
fn rejects_non_positive_or_non_finite_configuration() {
@@ -86,4 +87,16 @@ mod tests {
assert!(limiter.try_acquire());
assert!(!limiter.try_acquire());
}
#[test]
fn refills_tokens_over_time() {
let limiter = RateLimiter::new(100.0, 1).expect("rate limiter should build");
assert!(limiter.try_acquire());
assert!(!limiter.try_acquire());
// A production-grade limiter would inject a clock; a short sleep keeps
// this challenge implementation simple without coupling tests to internals.
std::thread::sleep(Duration::from_millis(20));
assert!(limiter.try_acquire());
}
}
+35
View File
@@ -246,6 +246,29 @@ async fn keeps_incoming_request_id_when_present() {
assert_eq!(response.headers()["x-request-id"], "external-id");
}
#[tokio::test]
async fn metrics_endpoint_exposes_http_and_upstream_otel_metrics() {
let pokeapi = MockServer::start().await;
let translations = MockServer::start().await;
mount_species(&pokeapi, "mewtwo", true, "rare", "Created by science.").await;
let app = create_app(state_for(&pokeapi, &translations));
let response = app
.clone()
.oneshot(request("/pokemon/mewtwo"))
.await
.expect("request should be handled");
assert_eq!(response.status(), StatusCode::OK);
let response = app
.oneshot(request("/metrics"))
.await
.expect("request should be handled");
let body = text_body(response).await;
assert!(body.contains("http_server_requests"));
assert!(body.contains("upstream_client_requests"));
}
#[tokio::test]
async fn rate_limit_rejects_requests_over_the_configured_burst() {
let pokeapi = MockServer::start().await;
@@ -341,6 +364,14 @@ async fn pokemon_endpoint_maps_missing_pokemon_to_404() {
assert_eq!(error["code"], "pokemon_not_found");
assert_eq!(error["message"], "pokemon not found");
assert_eq!(error["requestId"], "known-request-id");
let metrics = app
.oneshot(request("/metrics"))
.await
.expect("request should be handled");
let body = text_body(metrics).await;
assert!(body.contains("status=\"not_found\""));
assert!(!body.contains("upstream_client_errors"));
}
#[tokio::test]
@@ -474,6 +505,10 @@ async fn json_body(response: axum::response::Response) -> Value {
serde_json::from_slice(&bytes).expect("body should be valid JSON")
}
async fn text_body(response: axum::response::Response) -> String {
String::from_utf8(body_bytes(response).await.to_vec()).expect("body should be valid UTF-8")
}
async fn body_bytes(response: axum::response::Response) -> axum::body::Bytes {
to_bytes(response.into_body(), 1024 * 1024)
.await
+23
View File
@@ -0,0 +1,23 @@
use pokedex_api::telemetry::{Telemetry, UpstreamRequestOutcome};
use std::time::Duration;
#[test]
fn records_http_and_upstream_metrics_in_prometheus_text_format() {
let telemetry = Telemetry::new("test-service");
let metrics = telemetry.metrics();
metrics.record_http_request("GET", "/pokemon/{name}", 200, Duration::from_millis(10));
metrics.record_upstream_request(
"pokeapi",
"pokemon_species",
UpstreamRequestOutcome::Success,
Duration::from_millis(5),
);
let rendered = telemetry
.render_prometheus()
.expect("metrics should render as text");
assert!(rendered.contains("http_server_requests"));
assert!(rendered.contains("upstream_client_requests"));
assert!(rendered.contains("service_name=\"test-service\""));
}