diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..43675e7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# Keep the Docker build context small and platform-agnostic: never ship host +# build artifacts or VCS metadata into the image. +target/ +**/target/ +.git/ +.github/ +*.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 48a8fa1..1369d88 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -163,12 +163,6 @@ Eagerly implement common traits where appropriate: - Keep `main.rs` or `lib.rs` minimal - move logic to modules. - When a single module file grows too large, split it into a directory module with a `mod.rs` re-exporting its sub-modules. This keeps the public API identical while improving internal organization. -## Documentation - -- **Do not update `README.md` as the capabilities/support spec.** The README is being reworked into a user-facing guide by the maintainer; do not add feature-support matrices, capability bullets, or "Supported/Rejected" lists to it. -- When adding, changing, or removing generator features, focus on **code and tests** (fixtures + snapshots + coverage). Do not write capability/support documentation into `README.md`. -- Deeper technical details about what the generator supports (and doesn't) will live in a separate doc (a nested `README.md` or a `CONTRIBUTING` doc) — the location is undecided and the maintainer will reorganize docs. Leave capability documentation to that step unless explicitly asked. - ## Quality Checklist Before publishing or reviewing Rust code, ensure: @@ -187,4 +181,4 @@ Before publishing or reviewing Rust code, ensure: - [ ] **Performance**: Efficient use of iterators, minimal allocations - [ ] **API Design**: Functions are predictable, flexible, and type-safe - [ ] **Future Proofing**: Private fields in structs, sealed traits where appropriate -- [ ] **Tooling**: Code passes `cargo fmt`, `cargo clippy`, and `cargo test` +- [ ] **Tooling**: Code passes all workflows executed in the CI/CD pipeline diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..f68bec3 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,20 @@ +name: E2E + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + docker-e2e: + name: test + runs-on: ubuntu-latest + if: ${{ !startsWith(github.event.pull_request.title, 'chore:') }} + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Run Docker e2e (server + client over HTTP) + run: make test-e2e diff --git a/Cargo.lock b/Cargo.lock index 9ac4f23..df89b0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,8 +170,12 @@ version = "0.1.0" dependencies = [ "axum", "axum-extra", + "percent-encoding", + "reqwest", "serde", "serde_json", + "serde_yaml", + "tokio", ] [[package]] diff --git a/Makefile b/Makefile index 3de61a3..196f654 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ SHELL := /bin/bash .PHONY: help help: ## Show this help - @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-35s\033[0m %s\n", $$1, $$2}' .PHONY: clean @@ -43,6 +43,12 @@ test-unit: ## Run unit tests test-integration: ## Run integration tests cargo test --features integration --test '*_integration' -- --nocapture +.PHONY: test-e2e +test-e2e: ## Run the Docker end-to-end test (generated server + client over HTTP) + @compose="docker compose -f crates/oapi-codegen/tests/integration/docker-compose.yml"; \ + trap 'code=$$?; $$compose down --remove-orphans --volumes; exit $$code' EXIT; \ + $$compose up --build --exit-code-from client --abort-on-container-exit + .PHONY: update-generated update-generated: ## Refresh generated files from the coverage fixtures UPDATE_GENERATED=1 cargo test -p oapi-codegen --test coverage @@ -52,7 +58,8 @@ generate-example: ## Regenerate the composed bookstore example from its OpenAPI cd examples/bookstore && \ cargo run -q -p oapi-codegen -- schemas/common.yaml --config oapi-codegen-common.yaml && \ cargo run -q -p oapi-codegen -- schemas/catalog.yaml --config oapi-codegen-catalog.yaml && \ - cargo run -q -p oapi-codegen -- openapi.yaml --config oapi-codegen-server.yaml + cargo run -q -p oapi-codegen -- openapi.yaml --config oapi-codegen-server.yaml && \ + cargo run -q -p oapi-codegen -- openapi.yaml --config oapi-codegen-client.yaml .PHONY: verify-generated verify-generated: ## Regenerate all generated code and fail if it drifts from what is committed diff --git a/crates/oapi-codegen/tests/integration/client/Dockerfile b/crates/oapi-codegen/tests/integration/client/Dockerfile new file mode 100644 index 0000000..5d1a6c6 --- /dev/null +++ b/crates/oapi-codegen/tests/integration/client/Dockerfile @@ -0,0 +1,11 @@ +# Compile the generated blocking `reqwest` client and its e2e test, then run the +# test against the server named by BOOKSTORE_BASE_URL (set in docker-compose.yml). +# The image keeps the full toolchain because it runs `cargo test` at container +# start; pre-building with `--no-run` means startup goes straight to the tests. +# The build context is the repository root (see docker-compose.yml). + +FROM rust:1.96-bookworm AS builder +WORKDIR /src +COPY . . +RUN cargo test --no-run --locked -p bookstore-example --features client --test e2e +CMD ["cargo", "test", "--locked", "-p", "bookstore-example", "--features", "client", "--test", "e2e", "--", "--nocapture"] diff --git a/crates/oapi-codegen/tests/integration/docker-compose.yml b/crates/oapi-codegen/tests/integration/docker-compose.yml new file mode 100644 index 0000000..2361f26 --- /dev/null +++ b/crates/oapi-codegen/tests/integration/docker-compose.yml @@ -0,0 +1,29 @@ +name: bookstore-e2e + +# End-to-end harness: the `server` service runs the generated axum server and the +# `client` service compiles the generated reqwest client + integration test and +# drives every bookstore endpoint over HTTP. Run from the repository root with: +# +# docker compose -f crates/oapi-codegen/tests/integration/docker-compose.yml \ +# up --build --exit-code-from client --abort-on-container-exit +# +# The overall exit status is the client's, so CI fails iff the e2e tests fail. + +services: + server: + build: + context: ../../../.. + dockerfile: crates/oapi-codegen/tests/integration/server/Dockerfile + environment: + BOOKSTORE_ADDR: 0.0.0.0:8080 + expose: + - "8080" + + client: + build: + context: ../../../.. + dockerfile: crates/oapi-codegen/tests/integration/client/Dockerfile + depends_on: + - server + environment: + BOOKSTORE_BASE_URL: http://server:8080 diff --git a/crates/oapi-codegen/tests/integration/server/Dockerfile b/crates/oapi-codegen/tests/integration/server/Dockerfile new file mode 100644 index 0000000..1e1a5c9 --- /dev/null +++ b/crates/oapi-codegen/tests/integration/server/Dockerfile @@ -0,0 +1,14 @@ +# Build the bookstore server binary from the workspace, then run it from a slim +# runtime image. The build context is the repository root (see docker-compose.yml). + +FROM rust:1.96-bookworm AS builder +WORKDIR /src +COPY . . +RUN cargo build --release --locked -p bookstore-example --bin bookstore-server + +FROM debian:bookworm-slim AS runtime +WORKDIR /app +COPY --from=builder /src/target/release/bookstore-server /usr/local/bin/bookstore-server +ENV BOOKSTORE_ADDR=0.0.0.0:8080 +EXPOSE 8080 +CMD ["bookstore-server"] diff --git a/examples/bookstore/Cargo.toml b/examples/bookstore/Cargo.toml index cb3e14c..ad28825 100644 --- a/examples/bookstore/Cargo.toml +++ b/examples/bookstore/Cargo.toml @@ -9,8 +9,27 @@ publish = false [lints] workspace = true +[features] +# Compile the generated blocking `reqwest` client (`restclient`) and the e2e +# integration test that drives it. Kept optional so the default build stays lean +# and does not pull in `reqwest`; the Docker e2e image builds with this feature. +client = ["dep:reqwest", "dep:percent-encoding"] + [dependencies] axum = { version = "0.8.9", features = ["multipart"] } axum-extra = { version = "0.12.6", features = ["query"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" +tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "net"] } + +reqwest = { version = "0.12.28", default-features = false, features = ["blocking", "json", "multipart"], optional = true } +percent-encoding = { version = "2.3.2", optional = true } + +[dev-dependencies] +# Used only by the e2e coverage guard (`tests/e2e_coverage.rs`) to parse +# `openapi.yaml` and assert every operation has a registered e2e test. +serde_yaml = "0.9.34+deprecated" + +[[bin]] +name = "bookstore-server" +path = "src/bin/server.rs" diff --git a/examples/bookstore/generated/restclient.rs b/examples/bookstore/generated/restclient.rs new file mode 100644 index 0000000..3e0b7ca --- /dev/null +++ b/examples/bookstore/generated/restclient.rs @@ -0,0 +1,342 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. + +/// Errors returned by the generated client. +#[derive(Debug)] +pub enum ClientError { + /// The `reqwest` request failed to send or complete, including any + /// body decoding `reqwest` performs internally (such as JSON). + Http(reqwest::Error), + /// The server returned a status code the operation does not declare. + UnexpectedStatus(reqwest::StatusCode), + /// The response `Content-Type` matched none of the representations the + /// operation declares for its status. + UnexpectedContentType(String), + /// A response body failed to deserialize (e.g. malformed + /// form-urlencoded content). + Decode(String), +} +impl std::fmt::Display for ClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ClientError::Http(error) => return write!(f, "HTTP request failed: {error}"), + ClientError::UnexpectedStatus(status) => { + return write!(f, "unexpected response status: {status}"); + } + ClientError::UnexpectedContentType(content_type) => { + return write!(f, "unexpected response content type: {content_type}"); + } + ClientError::Decode(message) => { + return write!(f, "failed to decode response body: {message}"); + } + } + } +} +impl std::error::Error for ClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ClientError::Http(error) => return Some(error), + ClientError::UnexpectedStatus(_) + | ClientError::UnexpectedContentType(_) + | ClientError::Decode(_) => return None, + } + } +} +impl From for ClientError { + fn from(error: reqwest::Error) -> Self { + return ClientError::Http(error); + } +} + +const PATH_PARAM_ENCODE_SET: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC + .remove(b'-') + .remove(b'.') + .remove(b'_') + .remove(b'~'); + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct ListBooksQuery { + /// Restrict the listing to a single author. + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + /// Only return books carrying every given tag. + #[serde(skip_serializing_if = "Option::is_none")] + pub tag: Option>, + /// Maximum number of items to return. + #[serde(skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +/// List books, optionally filtered. +#[derive(Debug, Clone, PartialEq)] +pub enum ListBooksResponse { + /// The matching books. + Ok(Vec), +} + +pub struct CreateBookHeaders { + /// Unique key so a retried create is not duplicated. + pub idempotency_key: String, +} + +/// Add a book to the catalog. +#[derive(Debug, Clone, PartialEq)] +pub enum CreateBookResponse { + /// The book was created. + Created(crate::apimodel::catalog::Book), + /// The request was malformed. + BadRequest(crate::apimodel::common::ErrorResponse), + /// Authentication is required or has failed. + Unauthorized(crate::apimodel::common::ErrorResponse), +} + +/// Fetch a single book by id. +#[derive(Debug, Clone, PartialEq)] +pub enum GetBookResponse { + /// The requested book. + Ok(crate::apimodel::catalog::Book), + /// No resource matched the request. + NotFound, + /// An unexpected error; the handler sets the status code. + Default(reqwest::StatusCode, crate::apimodel::common::ErrorResponse), +} + +#[derive(Debug, Clone)] +pub struct UploadBookCoverMultipart { + pub image: Vec, + pub filename: String, + pub caption: Option, +} + +/// Upload or replace a book's cover image. +#[derive(Debug, Clone, PartialEq)] +pub enum UploadBookCoverResponse { + /// The cover image was stored. + NoContent, + /// No resource matched the request. + NotFound, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SubmitReviewRequestBody { + Json(crate::apimodel::catalog::NewReview), + Form(crate::apimodel::catalog::NewReview), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SubmitReviewResponseCreatedBody { + Json(crate::apimodel::catalog::Review), + Text(String), +} + +/// Submit a review as JSON or a form; read it back as JSON or plain text. +#[derive(Debug, Clone, PartialEq)] +pub enum SubmitReviewResponse { + /// The review was stored; returned as JSON or plain text. + Created(SubmitReviewResponseCreatedBody), + /// No resource matched the request. + NotFound, +} + +/// Liveness probe returning a free-form document. +#[derive(Debug, Clone, PartialEq)] +pub enum GetHealthResponse { + /// An opaque service-health document. + Ok(serde_json::Value), +} + +/// A blocking HTTP client for the API. +/// +/// `base_url` is used as a prefix for every request path and should not +/// carry a trailing slash (e.g. `https://api.example.com`). +#[derive(Debug, Clone)] +pub struct Client { + base_url: String, + http: reqwest::blocking::Client, +} + +impl Client { + /// Build a client targeting `base_url` with a default blocking + /// `reqwest::blocking::Client`. + pub fn new(base_url: impl Into) -> Result { + let http = reqwest::blocking::Client::builder().build()?; + return Ok(Self { + base_url: base_url.into(), + http, + }); + } + /// Build a client targeting `base_url` with a caller-provided + /// `reqwest::blocking::Client` (e.g. preconfigured with timeouts). + pub fn with_client( + base_url: impl Into, + http: reqwest::blocking::Client, + ) -> Self { + return Self { + base_url: base_url.into(), + http, + }; + } + /// List books, optionally filtered. + pub fn list_books( + &self, + query: ListBooksQuery, + ) -> Result { + let url = format!("{}/books", self.base_url); + let mut request = self.http.request(reqwest::Method::GET, url); + if let Some(value) = &query.author { + request = request.query(&[("author", value.to_string())]); + } + if let Some(items) = &query.tag { + for item in items { + request = request.query(&[("tag", item.to_string())]); + } + } + if let Some(value) = &query.limit { + request = request.query(&[("limit", value.to_string())]); + } + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Vec = response.json()?; + return Ok(ListBooksResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } + /// Add a book to the catalog. + pub fn create_book( + &self, + headers: CreateBookHeaders, + body: crate::apimodel::catalog::NewBook, + ) -> Result { + let url = format!("{}/books", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.header("Idempotency-Key", headers.idempotency_key.to_string()); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 201 { + let body: crate::apimodel::catalog::Book = response.json()?; + return Ok(CreateBookResponse::Created(body)); + } + if status.as_u16() == 400 { + let body: crate::apimodel::common::ErrorResponse = response.json()?; + return Ok(CreateBookResponse::BadRequest(body)); + } + if status.as_u16() == 401 { + let body: crate::apimodel::common::ErrorResponse = response.json()?; + return Ok(CreateBookResponse::Unauthorized(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } + /// Fetch a single book by id. + pub fn get_book(&self, id: String) -> Result { + let url = format!( + "{}/books/{}", self.base_url, percent_encoding::utf8_percent_encode(id + .as_str(), PATH_PARAM_ENCODE_SET) + ); + let response = self.http.request(reqwest::Method::GET, url).send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: crate::apimodel::catalog::Book = response.json()?; + return Ok(GetBookResponse::Ok(body)); + } + if status.as_u16() == 404 { + return Ok(GetBookResponse::NotFound); + } + let body: crate::apimodel::common::ErrorResponse = response.json()?; + return Ok(GetBookResponse::Default(status, body)); + } + /// Upload or replace a book's cover image. + pub fn upload_book_cover( + &self, + id: String, + body: UploadBookCoverMultipart, + ) -> Result { + let url = format!( + "{}/books/{}/cover", self.base_url, percent_encoding::utf8_percent_encode(id + .as_str(), PATH_PARAM_ENCODE_SET) + ); + let mut request = self.http.request(reqwest::Method::PUT, url); + let mut form = reqwest::blocking::multipart::Form::new(); + form = form + .part( + "image", + reqwest::blocking::multipart::Part::bytes(body.image).file_name("image"), + ); + form = form.text("filename", body.filename.to_string()); + if let Some(value) = &body.caption { + form = form.text("caption", value.to_string()); + } + request = request.multipart(form); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 204 { + return Ok(UploadBookCoverResponse::NoContent); + } + if status.as_u16() == 404 { + return Ok(UploadBookCoverResponse::NotFound); + } + return Err(ClientError::UnexpectedStatus(status)); + } + /// Submit a review as JSON or a form; read it back as JSON or plain text. + pub fn submit_review( + &self, + id: String, + body: SubmitReviewRequestBody, + ) -> Result { + let url = format!( + "{}/books/{}/reviews", self.base_url, + percent_encoding::utf8_percent_encode(id.as_str(), PATH_PARAM_ENCODE_SET) + ); + let mut request = self.http.request(reqwest::Method::POST, url); + match body { + SubmitReviewRequestBody::Json(value) => { + request = request.json(&value); + } + SubmitReviewRequestBody::Form(value) => { + request = request.form(&value); + } + } + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 201 { + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| return value.to_str().ok()) + .map(|value| { + return value + .split(';') + .next() + .unwrap_or(value) + .trim() + .to_ascii_lowercase(); + }) + .unwrap_or_default(); + let body = if content_type == "application/json" + || content_type.ends_with("+json") + { + SubmitReviewResponseCreatedBody::Json(response.json()?) + } else if content_type == "text/plain" { + SubmitReviewResponseCreatedBody::Text(response.text()?) + } else { + return Err(ClientError::UnexpectedContentType(content_type)); + }; + return Ok(SubmitReviewResponse::Created(body)); + } + if status.as_u16() == 404 { + return Ok(SubmitReviewResponse::NotFound); + } + return Err(ClientError::UnexpectedStatus(status)); + } + /// Liveness probe returning a free-form document. + pub fn get_health(&self) -> Result { + let url = format!("{}/health", self.base_url); + let response = self.http.request(reqwest::Method::GET, url).send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: serde_json::Value = response.json()?; + return Ok(GetHealthResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/examples/bookstore/oapi-codegen-client.yaml b/examples/bookstore/oapi-codegen-client.yaml new file mode 100644 index 0000000..f417d44 --- /dev/null +++ b/examples/bookstore/oapi-codegen-client.yaml @@ -0,0 +1,8 @@ +package: bookclient +output: generated/restclient.rs +generate: + client: true + models: false +import-mapping: + schemas/common.yaml: crate::apimodel::common + schemas/catalog.yaml: crate::apimodel::catalog diff --git a/examples/bookstore/src/bin/server.rs b/examples/bookstore/src/bin/server.rs new file mode 100644 index 0000000..5f27e05 --- /dev/null +++ b/examples/bookstore/src/bin/server.rs @@ -0,0 +1,37 @@ +//! A runnable bookstore server used by the Docker e2e integration test. +//! +//! It wires the shared in-memory [`bookstore_example::Service`] into the +//! generated axum router and serves it. The bind address comes from +//! `BOOKSTORE_ADDR` (default `0.0.0.0:8080`) so the container can override it. + +use std::net::SocketAddr; + +use bookstore_example::Service; +use bookstore_example::restapi; + +const DEFAULT_ADDR: &str = "0.0.0.0:8080"; + +#[tokio::main] +async fn main() { + let addr = bind_address(); + let listener = tokio::net::TcpListener::bind(addr) + .await + .expect("bind the bookstore server listener"); + + let local = listener.local_addr().expect("resolve the bound listener address"); + println!("bookstore server listening on http://{local}"); + + axum::serve(listener, restapi::router(Service::new())) + .await + .expect("serve the bookstore router"); +} + +/// Resolve the socket address to bind, honouring `BOOKSTORE_ADDR`. +fn bind_address() -> SocketAddr { + let raw = std::env::var("BOOKSTORE_ADDR").unwrap_or_else(|_| { + return DEFAULT_ADDR.to_owned(); + }); + return raw + .parse() + .unwrap_or_else(|error| panic!("BOOKSTORE_ADDR ({raw}) is not a valid socket address: {error}")); +} diff --git a/examples/bookstore/src/lib.rs b/examples/bookstore/src/lib.rs index ca8ac81..3f7a4ec 100644 --- a/examples/bookstore/src/lib.rs +++ b/examples/bookstore/src/lib.rs @@ -31,3 +31,15 @@ pub mod apimodel { pub mod restapi { include!("../generated/restapi.rs"); } + +/// The blocking `reqwest` client generated from `openapi.yaml`, used by the +/// Docker e2e integration test to exercise the running server over HTTP. +#[cfg(feature = "client")] +#[allow(dead_code, clippy::implicit_return)] +pub mod restclient { + include!("../generated/restclient.rs"); +} + +mod service; + +pub use crate::service::Service; diff --git a/examples/bookstore/src/service.rs b/examples/bookstore/src/service.rs new file mode 100644 index 0000000..18b944c --- /dev/null +++ b/examples/bookstore/src/service.rs @@ -0,0 +1,181 @@ +//! An in-memory implementation of the generated [`crate::restapi::Api`] trait. +//! +//! This backs both the smoke test (which proves the trait is implementable and +//! the router builds) and the `bookstore-server` binary used by the Docker e2e +//! integration test. State is a `Mutex`-guarded map so the type stays `Clone + +//! Send + Sync + 'static` as the trait requires; the lock is never held across +//! an `.await`, so each operation's future remains `Send`. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::Mutex; + +use crate::apimodel::catalog::Book; +use crate::apimodel::catalog::NewBook; +use crate::apimodel::catalog::Review; +use crate::apimodel::common::ErrorResponse; +use crate::restapi::Api; +use crate::restapi::CreateBookHeaders; +use crate::restapi::CreateBookResponse; +use crate::restapi::GetBookResponse; +use crate::restapi::GetHealthResponse; +use crate::restapi::ListBooksQuery; +use crate::restapi::ListBooksResponse; +use crate::restapi::SubmitReviewRequestBody; +use crate::restapi::SubmitReviewResponse; +use crate::restapi::SubmitReviewResponseCreatedBody; +use crate::restapi::UploadBookCoverMultipart; +use crate::restapi::UploadBookCoverResponse; + +/// An in-memory bookstore backing the generated [`Api`] trait. +#[derive(Clone, Default, Debug)] +pub struct Service { + books: Arc>>, +} + +impl Service { + /// Create an empty bookstore. + pub fn new() -> Self { + return Self::default(); + } + + /// Lock the book map, recovering the inner value if a previous holder + /// panicked so a poisoned lock cannot take the whole server down. + fn books(&self) -> std::sync::MutexGuard<'_, HashMap> { + match self.books.lock() { + Ok(guard) => { + return guard; + } + Err(poisoned) => { + return poisoned.into_inner(); + } + } + } +} + +impl Api for Service { + async fn list_books(&self, query: ListBooksQuery) -> ListBooksResponse { + let mut result: Vec = { + let books = self.books(); + let mut matched: Vec = Vec::new(); + for book in books.values() { + if let Some(author) = &query.author + && &book.author != author + { + continue; + } + if let Some(required) = &query.tag + && !has_all_tags(book, required) + { + continue; + } + matched.push(book.clone()); + } + matched + }; + + result.sort_by(|left, right| { + return left.id.cmp(&right.id); + }); + if let Some(limit) = query.limit { + result.truncate(limit.max(0) as usize); + } + return ListBooksResponse::Ok(result); + } + + async fn create_book(&self, headers: CreateBookHeaders, body: NewBook) -> CreateBookResponse { + if body.title.trim().is_empty() { + return CreateBookResponse::BadRequest(ErrorResponse { + code: "empty_title".to_owned(), + message: "title must not be empty".to_owned(), + }); + } + + let book = Book { + id: headers.idempotency_key, + title: body.title, + author: body.author, + price_cents: body.price_cents, + tags: body.tags, + }; + { + let mut books = self.books(); + books.insert(book.id.clone(), book.clone()); + } + return CreateBookResponse::Created(book); + } + + async fn get_book(&self, id: String) -> GetBookResponse { + let found = { + let books = self.books(); + books.get(&id).cloned() + }; + match found { + Some(book) => { + return GetBookResponse::Ok(book); + } + None => { + return GetBookResponse::NotFound; + } + } + } + + async fn upload_book_cover(&self, id: String, body: UploadBookCoverMultipart) -> UploadBookCoverResponse { + let exists = { + let books = self.books(); + books.contains_key(&id) + }; + if !exists || body.image.is_empty() || body.filename.is_empty() { + return UploadBookCoverResponse::NotFound; + } + return UploadBookCoverResponse::NoContent; + } + + async fn submit_review(&self, id: String, body: SubmitReviewRequestBody) -> SubmitReviewResponse { + let exists = { + let books = self.books(); + books.contains_key(&id) + }; + if !exists { + return SubmitReviewResponse::NotFound; + } + + let new_review = match body { + SubmitReviewRequestBody::Json(review) => review, + SubmitReviewRequestBody::Form(review) => review, + }; + let review = Review { + id: format!("{id}-review-1"), + rating: new_review.rating, + comment: new_review.comment, + }; + + if review.comment.is_none() { + return SubmitReviewResponse::Created(SubmitReviewResponseCreatedBody::Text(format!( + "stored review {} with rating {}", + review.id, review.rating + ))); + } + return SubmitReviewResponse::Created(SubmitReviewResponseCreatedBody::Json(review)); + } + + async fn get_health(&self) -> GetHealthResponse { + return GetHealthResponse::Ok(serde_json::json!({ "status": "ok" })); + } +} + +/// Whether `book` carries every tag in `required`. +fn has_all_tags(book: &Book, required: &[String]) -> bool { + let book_tags = match &book.tags { + Some(book_tags) => book_tags, + None => { + return required.is_empty(); + } + }; + for tag in required { + if !book_tags.contains(tag) { + return false; + } + } + return true; +} diff --git a/examples/bookstore/tests/e2e.rs b/examples/bookstore/tests/e2e.rs new file mode 100644 index 0000000..99e34fb --- /dev/null +++ b/examples/bookstore/tests/e2e.rs @@ -0,0 +1,342 @@ +//! End-to-end integration test: the generated blocking `reqwest` client drives +//! a running instance of the generated axum server over HTTP. +//! +//! This is deliberately excluded from the normal `cargo test` run: it only +//! compiles under the `client` feature, and even then every test is a no-op +//! unless `BOOKSTORE_BASE_URL` points at a running server. The Docker e2e +//! harness (see `crates/oapi-codegen/tests/integration`) sets that variable and +//! runs `cargo test --features client --test e2e`. + +#![cfg(feature = "client")] + +use std::time::Duration; + +use bookstore_example::apimodel::catalog::NewBook; +use bookstore_example::apimodel::catalog::NewReview; +use bookstore_example::restclient::Client; +use bookstore_example::restclient::CreateBookHeaders; +use bookstore_example::restclient::CreateBookResponse; +use bookstore_example::restclient::GetBookResponse; +use bookstore_example::restclient::GetHealthResponse; +use bookstore_example::restclient::ListBooksQuery; +use bookstore_example::restclient::ListBooksResponse; +use bookstore_example::restclient::SubmitReviewRequestBody; +use bookstore_example::restclient::SubmitReviewResponse; +use bookstore_example::restclient::SubmitReviewResponseCreatedBody; +use bookstore_example::restclient::UploadBookCoverMultipart; +use bookstore_example::restclient::UploadBookCoverResponse; + +const BASE_URL_ENV: &str = "BOOKSTORE_BASE_URL"; +const HEALTH_ATTEMPTS: u32 = 60; +const HEALTH_BACKOFF: Duration = Duration::from_millis(500); + +/// Build a client and block until the server answers `/health`, so tests do not +/// race container or server startup. +/// +/// Returns `None` when `BOOKSTORE_BASE_URL` is unset, which makes the whole +/// suite a no-op under a plain `cargo test` where no server is running. +fn connected_client() -> Option { + let base_url = match std::env::var(BASE_URL_ENV) { + Ok(base_url) => base_url.trim_end_matches('/').to_owned(), + Err(_) => { + return None; + } + }; + + let client = Client::new(base_url).expect("build the blocking reqwest client"); + let mut last_error = String::from("no attempt made"); + for _ in 0..HEALTH_ATTEMPTS { + match client.get_health() { + Ok(GetHealthResponse::Ok(_)) => { + return Some(client); + } + Err(error) => { + last_error = error.to_string(); + } + } + std::thread::sleep(HEALTH_BACKOFF); + } + panic!("server at {BASE_URL_ENV} never became healthy: {last_error}"); +} + +/// A per-test unique idempotency key so tests never collide on the shared, +/// stateful server instance. Combines a timestamp with a monotonic counter so +/// keys stay unique even when parallel tests read the same nanosecond or the +/// clock lookup falls back. +fn unique_key(prefix: &str) -> String { + static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| { + return elapsed.as_nanos(); + }) + .unwrap_or(0); + return format!("{prefix}-{nanos}-{counter}"); +} + +/// Create a book on the server and return its id. +fn seed_book(client: &Client, prefix: &str) -> String { + let key = unique_key(prefix); + let response = client + .create_book( + CreateBookHeaders { + idempotency_key: key.clone(), + }, + NewBook { + title: "The Rust Programming Language".to_owned(), + author: format!("author-{key}"), + price_cents: 3999, + tags: Some(vec!["rust".to_owned(), "programming".to_owned()]), + }, + ) + .expect("create_book request succeeds"); + + match response { + CreateBookResponse::Created(book) => { + assert_eq!(book.id, key); + return book.id; + } + other => { + panic!("expected Created, got {other:?}"); + } + } +} + +#[test] +fn health_endpoint_reports_ok() { + let client = match connected_client() { + Some(client) => client, + None => { + return; + } + }; + + let response = client.get_health().expect("get_health request succeeds"); + let GetHealthResponse::Ok(document) = response; + assert_eq!( + document.get("status").and_then(|value| return value.as_str()), + Some("ok") + ); +} + +#[test] +fn create_then_get_round_trips_a_book() { + let client = match connected_client() { + Some(client) => client, + None => { + return; + } + }; + + let id = seed_book(&client, "roundtrip"); + let response = client.get_book(id.clone()).expect("get_book request succeeds"); + match response { + GetBookResponse::Ok(book) => { + assert_eq!(book.id, id); + assert_eq!(book.title, "The Rust Programming Language"); + } + other => { + panic!("expected Ok, got {other:?}"); + } + } +} + +#[test] +fn create_rejects_an_empty_title() { + let client = match connected_client() { + Some(client) => client, + None => { + return; + } + }; + + let key = unique_key("empty-title"); + let response = client + .create_book( + CreateBookHeaders { idempotency_key: key }, + NewBook { + title: String::new(), + author: "nobody".to_owned(), + price_cents: 100, + tags: None, + }, + ) + .expect("create_book request succeeds"); + + match response { + CreateBookResponse::BadRequest(error) => { + assert_eq!(error.code, "empty_title"); + } + other => { + panic!("expected BadRequest, got {other:?}"); + } + } +} + +#[test] +fn get_unknown_book_is_not_found() { + let client = match connected_client() { + Some(client) => client, + None => { + return; + } + }; + + let response = client + .get_book(unique_key("missing")) + .expect("get_book request succeeds"); + match response { + GetBookResponse::NotFound => {} + other => { + panic!("expected NotFound, got {other:?}"); + } + } +} + +#[test] +fn list_filters_by_author() { + let client = match connected_client() { + Some(client) => client, + None => { + return; + } + }; + + let id = seed_book(&client, "listfilter"); + let author = format!("author-{id}"); + let response = client + .list_books(ListBooksQuery { + author: Some(author.clone()), + tag: None, + limit: None, + }) + .expect("list_books request succeeds"); + + let ListBooksResponse::Ok(books) = response; + assert_eq!(books.len(), 1, "exactly one book carries the unique author"); + assert_eq!(books[0].author, author); + assert_eq!(books[0].id, id); +} + +#[test] +fn upload_cover_for_known_and_unknown_books() { + let client = match connected_client() { + Some(client) => client, + None => { + return; + } + }; + + let id = seed_book(&client, "cover"); + let response = client + .upload_book_cover( + id, + UploadBookCoverMultipart { + image: vec![0x89, 0x50, 0x4E, 0x47], + filename: "cover.png".to_owned(), + caption: Some("front cover".to_owned()), + }, + ) + .expect("upload_book_cover request succeeds"); + match response { + UploadBookCoverResponse::NoContent => {} + other => { + panic!("expected NoContent, got {other:?}"); + } + } + + let missing = client + .upload_book_cover( + unique_key("cover-missing"), + UploadBookCoverMultipart { + image: vec![0x00], + filename: "cover.png".to_owned(), + caption: None, + }, + ) + .expect("upload_book_cover request succeeds"); + match missing { + UploadBookCoverResponse::NotFound => {} + other => { + panic!("expected NotFound, got {other:?}"); + } + } +} + +#[test] +fn submit_review_negotiates_json_and_text() { + let client = match connected_client() { + Some(client) => client, + None => { + return; + } + }; + + let id = seed_book(&client, "review"); + + // A review carrying a comment reads back as structured JSON. + let json_response = client + .submit_review( + id.clone(), + SubmitReviewRequestBody::Json(NewReview { + rating: 5, + comment: Some("superb".to_owned()), + }), + ) + .expect("submit_review request succeeds"); + match json_response { + SubmitReviewResponse::Created(SubmitReviewResponseCreatedBody::Json(review)) => { + assert_eq!(review.rating, 5); + assert_eq!(review.comment.as_deref(), Some("superb")); + } + other => { + panic!("expected Created(Json), got {other:?}"); + } + } + + // A commentless review echoes back as plain text. + let text_response = client + .submit_review( + id, + SubmitReviewRequestBody::Form(NewReview { + rating: 3, + comment: None, + }), + ) + .expect("submit_review request succeeds"); + match text_response { + SubmitReviewResponse::Created(SubmitReviewResponseCreatedBody::Text(text)) => { + assert!(text.contains("rating 3"), "unexpected text body: {text}"); + } + other => { + panic!("expected Created(Text), got {other:?}"); + } + } +} + +#[test] +fn submit_review_for_unknown_book_is_not_found() { + let client = match connected_client() { + Some(client) => client, + None => { + return; + } + }; + + let response = client + .submit_review( + unique_key("review-missing"), + SubmitReviewRequestBody::Json(NewReview { + rating: 1, + comment: Some("who?".to_owned()), + }), + ) + .expect("submit_review request succeeds"); + match response { + SubmitReviewResponse::NotFound => {} + other => { + panic!("expected NotFound, got {other:?}"); + } + } +} diff --git a/examples/bookstore/tests/e2e_coverage.rs b/examples/bookstore/tests/e2e_coverage.rs new file mode 100644 index 0000000..40a1cf7 --- /dev/null +++ b/examples/bookstore/tests/e2e_coverage.rs @@ -0,0 +1,84 @@ +//! Coverage guard for the e2e suite. +//! +//! Every operation declared in `openapi.yaml` must have a registered e2e test. +//! Unlike `tests/e2e.rs`, this file is **not** behind the `client` feature and +//! needs no running server, so it executes in the default `cargo test` gate: +//! adding an endpoint to the spec fails CI until it is both implemented and +//! covered. +//! +//! When you add an operation to `openapi.yaml`, add a matching `#[test]` in +//! `tests/e2e.rs` and its `operationId` to [`TESTED_OPERATIONS`] below. + +use std::collections::BTreeSet; + +/// Operations exercised by `tests/e2e.rs`, keyed by their OpenAPI `operationId`. +/// +/// Keep this in lockstep with `openapi.yaml`: the test below fails if the spec +/// declares an operation missing here (untested endpoint) or if an entry here no +/// longer exists in the spec (stale registration). +const TESTED_OPERATIONS: &[&str] = &[ + "listBooks", + "createBook", + "getBook", + "uploadBookCover", + "submitReview", + "getHealth", +]; + +#[test] +fn every_operation_has_a_registered_e2e_test() { + let declared = declared_operation_ids(include_str!("../openapi.yaml")); + let tested: BTreeSet = TESTED_OPERATIONS + .iter() + .map(|operation| { + return (*operation).to_owned(); + }) + .collect(); + + let untested: Vec<&String> = declared.difference(&tested).collect(); + assert!( + untested.is_empty(), + "operations in openapi.yaml with no registered e2e test — add a test in \ + tests/e2e.rs and an entry in TESTED_OPERATIONS: {untested:?}", + ); + + let stale: Vec<&String> = tested.difference(&declared).collect(); + assert!( + stale.is_empty(), + "TESTED_OPERATIONS entries that no longer exist in openapi.yaml — remove \ + them: {stale:?}", + ); +} + +/// Collect every `operationId` declared under `paths..` in the +/// OpenAPI document. Non-operation path-item entries (e.g. `parameters`) carry +/// no `operationId` and are skipped. +fn declared_operation_ids(spec: &str) -> BTreeSet { + let document: serde_yaml::Value = serde_yaml::from_str(spec).expect("parse openapi.yaml"); + let paths = match document.get("paths").and_then(|paths| { + return paths.as_mapping(); + }) { + Some(paths) => paths, + None => { + return BTreeSet::new(); + } + }; + + let mut ids = BTreeSet::new(); + for (_path, item) in paths { + let methods = match item.as_mapping() { + Some(methods) => methods, + None => { + continue; + } + }; + for (_method, operation) in methods { + if let Some(id) = operation.get("operationId").and_then(|id| { + return id.as_str(); + }) { + ids.insert(id.to_owned()); + } + } + } + return ids; +} diff --git a/examples/bookstore/tests/smoke.rs b/examples/bookstore/tests/smoke.rs index 240022e..c44c320 100644 --- a/examples/bookstore/tests/smoke.rs +++ b/examples/bookstore/tests/smoke.rs @@ -1,142 +1,19 @@ -//! Smoke test for the composed example: a hand-written `Api` implementation -//! backed by the generated model types, wired into the generated axum router. +//! Smoke test for the composed example: the shared in-memory [`Service`] +//! (see `src/service.rs`) implements the generated `Api` trait, and we wire it +//! into the generated axum router. //! //! This proves the whole pipeline composes — the server's cross-file `$ref`s //! resolve to the generated `apimodel` modules, the trait's native async methods //! are implementable, and the `Router` builder accepts the implementation. We -//! deliberately avoid pulling in an async runtime: building the router exercises -//! all of the generated wiring, and the `impl Api` block proves every operation -//! is implementable in terms of the imported model types. +//! deliberately avoid pulling in an async runtime here: building the router +//! exercises all of the generated wiring without invoking any handler. +use bookstore_example::Service; use bookstore_example::apimodel::catalog::Book; -use bookstore_example::apimodel::catalog::NewBook; -use bookstore_example::apimodel::catalog::Review; -use bookstore_example::apimodel::common::ErrorResponse; -use bookstore_example::restapi::Api; -use bookstore_example::restapi::CreateBookHeaders; -use bookstore_example::restapi::CreateBookResponse; -use bookstore_example::restapi::GetBookResponse; -use bookstore_example::restapi::GetHealthResponse; -use bookstore_example::restapi::ListBooksQuery; -use bookstore_example::restapi::ListBooksResponse; -use bookstore_example::restapi::SubmitReviewRequestBody; -use bookstore_example::restapi::SubmitReviewResponse; -use bookstore_example::restapi::SubmitReviewResponseCreatedBody; -use bookstore_example::restapi::UploadBookCoverMultipart; -use bookstore_example::restapi::UploadBookCoverResponse; - -#[derive(Clone)] -struct Service; - -impl Api for Service { - async fn list_books(&self, query: ListBooksQuery) -> ListBooksResponse { - let author = match query.author { - Some(author) => author, - None => "Klabnik & Nichols".to_owned(), - }; - let book = Book { - id: "book-1".to_owned(), - title: "The Rust Programming Language".to_owned(), - author, - price_cents: 3999, - tags: query.tag, - }; - - return ListBooksResponse::Ok(vec![book]); - } - - async fn create_book(&self, headers: CreateBookHeaders, body: NewBook) -> CreateBookResponse { - if body.title.is_empty() { - return CreateBookResponse::BadRequest(ErrorResponse { - code: "empty_title".to_owned(), - message: "title must not be empty".to_owned(), - }); - } - - return CreateBookResponse::Created(Book { - id: headers.idempotency_key, - title: body.title, - author: body.author, - price_cents: body.price_cents, - tags: body.tags, - }); - } - - async fn get_book(&self, id: String) -> GetBookResponse { - if id == "book-1" { - return GetBookResponse::Ok(Book { - id, - title: "The Rust Programming Language".to_owned(), - author: "Klabnik & Nichols".to_owned(), - price_cents: 3999, - tags: Some(vec!["rust".to_owned()]), - }); - } - - if id.is_empty() { - // The `default` response lets the handler pick the status code. - return GetBookResponse::Default( - axum::http::StatusCode::BAD_REQUEST, - ErrorResponse { - code: "missing_id".to_owned(), - message: "a book id is required".to_owned(), - }, - ); - } - - return GetBookResponse::NotFound; - } - - async fn get_health(&self) -> GetHealthResponse { - return GetHealthResponse::Ok(serde_json::json!({ "status": "ok" })); - } - - async fn upload_book_cover(&self, id: String, body: UploadBookCoverMultipart) -> UploadBookCoverResponse { - // The multipart body decodes to a dedicated extractor struct: the binary - // `image` part is `Vec`, required text parts are bare, and the - // optional `caption` is `Option`. - if id.is_empty() || body.image.is_empty() || body.filename.is_empty() { - return UploadBookCoverResponse::NotFound; - } - - let _caption: Option = body.caption; - return UploadBookCoverResponse::NoContent; - } - - async fn submit_review(&self, id: String, body: SubmitReviewRequestBody) -> SubmitReviewResponse { - // The request body decodes from either JSON or a form; both carry the - // same `NewReview` shape, so collapse them before storing. - let new_review = match body { - SubmitReviewRequestBody::Json(review) => review, - SubmitReviewRequestBody::Form(review) => review, - }; - - if id.is_empty() { - return SubmitReviewResponse::NotFound; - } - - let review = Review { - id: format!("{id}-review-1"), - rating: new_review.rating, - comment: new_review.comment, - }; - - // The handler chooses the response representation: terse reviews echo - // back as plain text, richer ones as structured JSON. - if review.comment.is_none() { - return SubmitReviewResponse::Created(SubmitReviewResponseCreatedBody::Text(format!( - "stored review {} with rating {}", - review.id, review.rating - ))); - } - - return SubmitReviewResponse::Created(SubmitReviewResponseCreatedBody::Json(review)); - } -} #[test] fn router_builds_from_a_real_api_implementation() { - let _router = bookstore_example::restapi::router(Service); + let _router = bookstore_example::restapi::router(Service::new()); } #[test]