From 506f41df58e3b9bc20e9b53f0be487ae061940e8 Mon Sep 17 00:00:00 2001 From: "alchemax-housekeeper[bot]" <258233519+alchemax-housekeeper[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:54:48 +0000 Subject: [PATCH 1/2] fix(deps): update cargo dependencies (#122) Co-authored-by: alchemax-housekeeper[bot] <258233519+alchemax-housekeeper[bot]@users.noreply.github.com> --- Cargo.lock | 71 +++++++++++++++++----------------- crates/oapi-codegen/Cargo.toml | 2 +- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c09a50..ff0b9de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -212,9 +212,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "shlex 2.0.1", @@ -418,9 +418,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "form_urlencoded" @@ -629,9 +629,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -643,9 +643,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -656,9 +656,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -670,16 +670,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -690,15 +691,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -785,9 +786,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "log" @@ -954,9 +955,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1392,9 +1393,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -1597,9 +1598,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.24.0" +version = "1.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" dependencies = [ "js-sys", "serde_core", @@ -1799,9 +1800,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "yoke" @@ -1849,9 +1850,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -1860,9 +1861,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -1871,13 +1872,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/crates/oapi-codegen/Cargo.toml b/crates/oapi-codegen/Cargo.toml index 9ee0cac..1fd523e 100644 --- a/crates/oapi-codegen/Cargo.toml +++ b/crates/oapi-codegen/Cargo.toml @@ -53,4 +53,4 @@ reqwest = { version = "0.13.4", default-features = false, features = ["blocking" serde = { version = "1.0.229", features = ["derive"] } serde_urlencoded = "0.7.1" trycmd = "1.2.1" -uuid = { version = "1.24.0", features = ["serde"] } +uuid = { version = "1.24.1", features = ["serde"] } From b619907a062767f34cc409820a9973fcdf32e1d3 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:03:08 +0000 Subject: [PATCH 2/2] feat: generate concern-level file structures instead of one large `restapi.rs` (#120) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Kaspar Lyngsie --- README.md | 2 +- crates/oapi-codegen/src/console.rs | 79 +- crates/oapi-codegen/src/emit/axum.rs | 104 ++- crates/oapi-codegen/src/emit/mod.rs | 33 +- crates/oapi-codegen/src/emit/package.rs | 346 +++++++++ crates/oapi-codegen/src/emit/reqwest.rs | 84 ++- crates/oapi-codegen/src/error.rs | 50 ++ crates/oapi-codegen/src/lib.rs | 122 ++- crates/oapi-codegen/src/main.rs | 47 +- crates/oapi-codegen/src/package.rs | 375 ++++++++++ crates/oapi-codegen/tests/check_mode.rs | 148 ++++ crates/oapi-codegen/tests/coverage.rs | 73 ++ .../fixtures/combined_keyword_operations.yaml | 70 ++ crates/oapi-codegen/tests/generated.rs | 18 + .../tests/generated/allof_merge.rs | 1 + .../tests/generated/anyof_untagged.rs | 1 + .../tests/generated/array_types.rs | 1 + .../tests/generated/client_auth.rs | 1 + .../tests/generated/client_form_response.rs | 1 + .../generated/client_multipart_request.rs | 1 + .../generated/client_negotiated_request.rs | 1 + .../generated/client_negotiated_response.rs | 1 + .../tests/generated/client_widgets.rs | 1 + .../generated/combined_keyword_operations.rs | 287 +++++++ .../generated/combined_prelude_value_names.rs | 1 + .../combined_response_name_collision.rs | 1 + .../tests/generated/combined_server_client.rs | 1 + .../tests/generated/combined_x_rust_derive.rs | 1 + .../tests/generated/compose_shared.rs | 1 + .../tests/generated/ext_vendor_extensions.rs | 1 + .../tests/generated/ext_x_rust_derive.rs | 1 + .../tests/generated/ext_x_rust_type.rs | 1 + .../tests/generated/freeform_any.rs | 1 + .../tests/generated/integer_enum.rs | 1 + .../tests/generated/integer_formats.rs | 1 + .../oapi-codegen/tests/generated/map_alias.rs | 1 + .../tests/generated/metadata_docs.rs | 1 + .../tests/generated/multi_spec_compose.rs | 1 + .../oapi-codegen/tests/generated/nullable.rs | 1 + .../tests/generated/number_formats.rs | 1 + .../generated/object_additional_properties.rs | 1 + .../generated/object_deny_unknown_fields.rs | 1 + .../tests/generated/object_nested_inline.rs | 1 + .../generated/object_optional_required.rs | 1 + .../tests/generated/oneof_discriminator.rs | 1 + .../tests/generated/oneof_untagged.rs | 1 + .../tests/generated/oneof_variant_naming.rs | 1 + .../package_combined_keyword_operations.rs | 23 + .../client.rs | 106 +++ .../client/match.rs | 37 + .../client/mod_.rs | 27 + .../client/self_.rs | 27 + .../client/type.rs | 27 + .../models.rs | 15 + .../operations.rs | 23 + .../operations/match.rs | 18 + .../operations/mod_.rs | 18 + .../operations/self_.rs | 18 + .../operations/type.rs | 18 + .../server.rs | 48 ++ .../server/match.rs | 38 + .../server/mod_.rs | 36 + .../server/self_.rs | 36 + .../server/type.rs | 36 + .../package_combined_prelude_value_names.rs | 23 + .../client.rs | 96 +++ .../client/probe.rs | 38 + .../client/upload.rs | 37 + .../models.rs | 35 + .../operations.rs | 17 + .../operations/probe.rs | 28 + .../operations/upload.rs | 25 + .../server.rs | 40 + .../server/probe.rs | 55 ++ .../server/upload.rs | 91 +++ ...ackage_combined_response_name_collision.rs | 23 + .../client.rs | 102 +++ .../client/get_widget.rs | 33 + .../client/replace_widget.rs | 39 + .../models.rs | 17 + .../operations.rs | 17 + .../operations/get_widget.rs | 19 + .../operations/replace_widget.rs | 19 + .../server.rs | 45 ++ .../server/get_widget.rs | 37 + .../server/replace_widget.rs | 38 + .../package_combined_server_client.rs | 23 + .../package_combined_server_client/client.rs | 102 +++ .../client/get_order.rs | 35 + .../client/replace_order.rs | 38 + .../package_combined_server_client/models.rs | 18 + .../operations.rs | 17 + .../operations/get_order.rs | 21 + .../operations/replace_order.rs | 19 + .../package_combined_server_client/server.rs | 45 ++ .../server/get_order.rs | 46 ++ .../server/replace_order.rs | 38 + .../package_combined_x_rust_derive.rs | 23 + .../package_combined_x_rust_derive/client.rs | 100 +++ .../client/create_thing.rs | 30 + .../client/get_report.rs | 49 ++ .../client/list_things.rs | 28 + .../client/upload.rs | 32 + .../package_combined_x_rust_derive/models.rs | 18 + .../operations.rs | 23 + .../operations/create_thing.rs | 19 + .../operations/get_report.rs | 25 + .../operations/list_things.rs | 19 + .../operations/upload.rs | 25 + .../package_combined_x_rust_derive/server.rs | 58 ++ .../server/create_thing.rs | 37 + .../server/get_report.rs | 41 + .../server/list_things.rs | 36 + .../server/upload.rs | 106 +++ .../tests/generated/prelude_result_name.rs | 1 + .../tests/generated/prelude_value_names.rs | 1 + .../tests/generated/primitive_scalars.rs | 1 + .../tests/generated/recursive_schema.rs | 1 + .../oapi-codegen/tests/generated/ref_local.rs | 1 + .../tests/generated/schema_defaults.rs | 1 + .../tests/generated/server_auth.rs | 1 + .../generated/server_component_body_ref.rs | 1 + .../generated/server_component_param_ref.rs | 1 + .../server_component_param_ref_pet.rs | 1 + .../tests/generated/server_cookie_params.rs | 1 + .../server_default_range_responses.rs | 1 + .../tests/generated/server_filtering.rs | 1 + .../tests/generated/server_form_body.rs | 1 + .../tests/generated/server_header_params.rs | 1 + .../tests/generated/server_json_charset.rs | 1 + .../generated/server_multi_content_request.rs | 1 + .../server_multi_content_response.rs | 1 + .../tests/generated/server_multipart_body.rs | 1 + .../tests/generated/server_petstore.rs | 1 + .../tests/generated/server_prune.rs | 1 + .../tests/generated/server_query_params.rs | 1 + .../tests/generated/server_refs.rs | 1 + .../generated/server_response_headers.rs | 1 + .../tests/generated/server_text_body.rs | 1 + .../tests/generated/server_urls.rs | 1 + .../tests/generated/server_xfile_refs.rs | 1 + .../tests/generated/string_enum.rs | 1 + .../tests/generated/string_formats.rs | 1 + .../tests/generated/type_name_collisions.rs | 1 + .../tests/generated/value_constraints.rs | 1 + crates/oapi-codegen/tests/package.rs | 698 ++++++++++++++++++ docs/configuration.md | 14 +- docs/design.md | 26 +- docs/workflow.md | 78 +- examples/bookstore/README.md | 4 +- .../bookstore/generated/apimodel/catalog.rs | 1 + .../bookstore/generated/apimodel/common.rs | 1 + examples/bookstore/generated/restapi.rs | 553 +------------- .../bookstore/generated/restapi/operations.rs | 29 + .../restapi/operations/create_book.rs | 27 + .../generated/restapi/operations/get_book.rs | 21 + .../restapi/operations/get_health.rs | 17 + .../restapi/operations/list_books.rs | 30 + .../restapi/operations/submit_review.rs | 31 + .../restapi/operations/upload_book_cover.rs | 26 + .../bookstore/generated/restapi/server.rs | 79 ++ .../generated/restapi/server/create_book.rs | 88 +++ .../generated/restapi/server/get_book.rs | 48 ++ .../generated/restapi/server/get_health.rs | 35 + .../generated/restapi/server/list_books.rs | 36 + .../generated/restapi/server/submit_review.rs | 108 +++ .../restapi/server/upload_book_cover.rs | 129 ++++ .../generated/restapi/server_urls.rs | 48 ++ examples/bookstore/generated/restclient.rs | 346 +-------- .../bookstore/generated/restclient/client.rs | 110 +++ .../restclient/client/create_book.rs | 42 ++ .../generated/restclient/client/get_book.rs | 35 + .../generated/restclient/client/get_health.rs | 27 + .../generated/restclient/client/list_books.rs | 42 ++ .../restclient/client/submit_review.rs | 68 ++ .../restclient/client/upload_book_cover.rs | 49 ++ .../generated/restclient/operations.rs | 29 + .../restclient/operations/create_book.rs | 27 + .../restclient/operations/get_book.rs | 21 + .../restclient/operations/get_health.rs | 17 + .../restclient/operations/list_books.rs | 27 + .../restclient/operations/submit_review.rs | 31 + .../operations/upload_book_cover.rs | 26 + 183 files changed, 6415 insertions(+), 1006 deletions(-) create mode 100644 crates/oapi-codegen/src/emit/package.rs create mode 100644 crates/oapi-codegen/src/package.rs create mode 100644 crates/oapi-codegen/tests/fixtures/combined_keyword_operations.yaml create mode 100644 crates/oapi-codegen/tests/generated/combined_keyword_operations.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/match.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/mod_.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/self_.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/type.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/models.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/match.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/mod_.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/self_.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/type.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/match.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/mod_.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/self_.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/type.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_prelude_value_names.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/client.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/client/probe.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/client/upload.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/models.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations/probe.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations/upload.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/server.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/server/probe.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/server/upload.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_response_name_collision.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_response_name_collision/client.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_response_name_collision/client/get_widget.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_response_name_collision/client/replace_widget.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_response_name_collision/models.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_response_name_collision/operations.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_response_name_collision/operations/get_widget.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_response_name_collision/operations/replace_widget.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_response_name_collision/server.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_response_name_collision/server/get_widget.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_response_name_collision/server/replace_widget.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_server_client.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_server_client/client.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_server_client/client/get_order.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_server_client/client/replace_order.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_server_client/models.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_server_client/operations.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_server_client/operations/get_order.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_server_client/operations/replace_order.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_server_client/server.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_server_client/server/get_order.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_server_client/server/replace_order.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/create_thing.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/get_report.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/list_things.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/upload.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/models.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/create_thing.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/get_report.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/list_things.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/upload.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/create_thing.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/get_report.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/list_things.rs create mode 100644 crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/upload.rs create mode 100644 crates/oapi-codegen/tests/package.rs create mode 100644 examples/bookstore/generated/restapi/operations.rs create mode 100644 examples/bookstore/generated/restapi/operations/create_book.rs create mode 100644 examples/bookstore/generated/restapi/operations/get_book.rs create mode 100644 examples/bookstore/generated/restapi/operations/get_health.rs create mode 100644 examples/bookstore/generated/restapi/operations/list_books.rs create mode 100644 examples/bookstore/generated/restapi/operations/submit_review.rs create mode 100644 examples/bookstore/generated/restapi/operations/upload_book_cover.rs create mode 100644 examples/bookstore/generated/restapi/server.rs create mode 100644 examples/bookstore/generated/restapi/server/create_book.rs create mode 100644 examples/bookstore/generated/restapi/server/get_book.rs create mode 100644 examples/bookstore/generated/restapi/server/get_health.rs create mode 100644 examples/bookstore/generated/restapi/server/list_books.rs create mode 100644 examples/bookstore/generated/restapi/server/submit_review.rs create mode 100644 examples/bookstore/generated/restapi/server/upload_book_cover.rs create mode 100644 examples/bookstore/generated/restapi/server_urls.rs create mode 100644 examples/bookstore/generated/restclient/client.rs create mode 100644 examples/bookstore/generated/restclient/client/create_book.rs create mode 100644 examples/bookstore/generated/restclient/client/get_book.rs create mode 100644 examples/bookstore/generated/restclient/client/get_health.rs create mode 100644 examples/bookstore/generated/restclient/client/list_books.rs create mode 100644 examples/bookstore/generated/restclient/client/submit_review.rs create mode 100644 examples/bookstore/generated/restclient/client/upload_book_cover.rs create mode 100644 examples/bookstore/generated/restclient/operations.rs create mode 100644 examples/bookstore/generated/restclient/operations/create_book.rs create mode 100644 examples/bookstore/generated/restclient/operations/get_book.rs create mode 100644 examples/bookstore/generated/restclient/operations/get_health.rs create mode 100644 examples/bookstore/generated/restclient/operations/list_books.rs create mode 100644 examples/bookstore/generated/restclient/operations/submit_review.rs create mode 100644 examples/bookstore/generated/restclient/operations/upload_book_cover.rs diff --git a/README.md b/README.md index dc61953..802f491 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ From this repository's root (e.g., after cloning), try the included bookstore ex ```console $ oapi-codegen --config-file examples/bookstore/oapi-codegen-server.yaml examples/bookstore/openapi.yaml -✓ wrote generated/restapi.rs +✓ wrote generated/restapi.rs and 15 modules beside it note: add the crates the generated code references to Cargo.toml: serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" diff --git a/crates/oapi-codegen/src/console.rs b/crates/oapi-codegen/src/console.rs index 312aa0e..c6ab02d 100644 --- a/crates/oapi-codegen/src/console.rs +++ b/crates/oapi-codegen/src/console.rs @@ -127,9 +127,23 @@ pub fn report_empty_output(spec: &Path, stats: &SpecStats, generate: &Generate) } } -/// Report a successful write to `path`. -pub fn report_wrote(path: &Path) { - eprintln!("{} wrote {}", "✓".green().bold(), path.display()); +/// Report that generation wrote its output. +/// +/// A run that splits its output names the root file and counts the rest: the +/// root is the file a consumer mounts, and the count says the tree beside it is +/// part of the same artifact. +pub fn report_wrote(path: &Path, files: usize) { + let modules = files.saturating_sub(1); + if modules == 0 { + eprintln!("{} wrote {}", "✓".green().bold(), path.display()); + return; + } + let plural = if modules == 1 { "module" } else { "modules" }; + eprintln!( + "{} wrote {} and {modules} {plural} beside it", + "✓".green().bold(), + path.display() + ); } /// Report that `--check` found `path` up to date. @@ -137,24 +151,35 @@ pub fn report_check_passed(path: &Path) { eprintln!("{} {} is up to date", "✓".green().bold(), path.display()); } +/// What `--check` found at the file it stopped on. +#[derive(Debug, Clone, Copy)] +pub enum DriftKind { + /// Generation writes the file, and it is not there. + Absent, + /// The file is there and holds different content. + Differs, + /// An earlier run wrote the file, and this run does not produce it. + Stale, +} + /// Report that `--check` found drift, and name the command that resolves it. /// -/// The message states which of the two cases holds, because an absent file and a -/// stale file need the reader to look at different things. Both have one remedy, -/// which is a run with no `--check`. -pub fn report_drift(path: &Path, absent: bool) { - if absent { - eprintln!( - "{} {} does not exist.", - "error:".red().bold(), - path.display().to_string().bold() - ); - } else { - eprintln!( - "{} {} is out of date with the spec.", - "error:".red().bold(), - path.display().to_string().bold() - ); +/// The message states which of the three cases holds, because a missing file, a +/// stale file, and a leftover file each need the reader to look at something +/// different. All three have one remedy, which is a run with no `--check`. +pub fn report_drift(path: &Path, kind: DriftKind) { + let path = path.display().to_string(); + let name = path.bold(); + match kind { + DriftKind::Absent => { + eprintln!("{} {name} does not exist.", "error:".red().bold()); + } + DriftKind::Differs => { + eprintln!("{} {name} is out of date with the spec.", "error:".red().bold()); + } + DriftKind::Stale => { + eprintln!("{} {name} is left over from an earlier run.", "error:".red().bold()); + } } eprintln!( " {} run the same command without `--check` to update it, and commit the result.", @@ -242,6 +267,22 @@ fn hints_for(err: &Error) -> Vec { Error::ReadOutput { path, .. } => { return vec![format!("Check that `{path}` is a readable file and not a directory.")]; } + Error::UnownedOutput { path } => { + return vec![ + format!("Move `{path}` out of the generated directory, or delete it."), + "The generator owns that directory and removes the files it no longer produces, so it never deletes a file it did not write.".to_owned(), + ]; + } + Error::OutsideOutput { directory, .. } => { + return vec![format!( + "This is an internal bug in oapi-codegen. Every generated file belongs under `{directory}`." + )]; + } + Error::UnsplittableOutput { path } => { + return vec![format!( + "Give the output path a `.rs` extension, for example `{path}.rs`." + )]; + } Error::Unimplemented(_) => { return vec!["This generation mode is not supported yet.".to_owned()]; } diff --git a/crates/oapi-codegen/src/emit/axum.rs b/crates/oapi-codegen/src/emit/axum.rs index d9f1d84..e3b2e60 100644 --- a/crates/oapi-codegen/src/emit/axum.rs +++ b/crates/oapi-codegen/src/emit/axum.rs @@ -38,7 +38,72 @@ pub(crate) const API_TRAIT_NAME: &str = "Api"; impl crate::emit::ServerEmitter for AxumServer { fn emit(&self, service: &Service) -> Result> { - return service_items(service); + return Ok(server_items(service, HandlerVisibility::Private)?.into_flat()); + } +} + +/// How widely an emitted handler function is visible. +/// +/// A flat file keeps the handler private, because the router that names it sits +/// beside it. A package puts the two in different modules, so the handler has to +/// reach its parent. +#[derive(Debug, Clone, Copy)] +pub(crate) enum HandlerVisibility { + /// No visibility keyword: the handler is reachable within its own module. + Private, + /// `pub(super)`: the handler is reachable from the module holding the router. + Parent, +} + +impl HandlerVisibility { + /// The visibility tokens to place before `async fn`. + fn to_tokens(self) -> TokenStream { + return match self { + HandlerVisibility::Private => quote! {}, + HandlerVisibility::Parent => quote! { pub(super) }, + }; + } +} + +/// The axum items one operation contributes, kept apart so a package can put +/// them in that operation's own file. +pub(crate) struct ServerOperationItems { + /// The `FromRequestParts`/`FromRequest` impls for the operation's inputs. + pub extractors: Vec, + /// The `IntoResponse` impl for the operation's response enum. + pub into_response: TokenStream, + /// The handler function the router dispatches to. + pub handler: TokenStream, +} + +/// The axum server interface, split into the items shared by every operation and +/// the items each operation contributes. +pub(crate) struct ServerItems { + /// One entry per operation, in `service.operations` order. + pub operations: Vec, + /// The `Api` trait. + pub api_trait: TokenStream, + /// The `router` builder. + pub router: TokenStream, +} + +impl ServerItems { + /// Flatten into the single-file item order: every extractor, the trait, every + /// `IntoResponse` impl, the router, then every handler. + pub fn into_flat(self) -> Vec { + let mut items = Vec::new(); + for operation in &self.operations { + items.extend(operation.extractors.iter().cloned()); + } + items.push(self.api_trait); + for operation in &self.operations { + items.push(operation.into_response.clone()); + } + items.push(self.router); + for operation in self.operations { + items.push(operation.handler); + } + return items; } } @@ -99,30 +164,32 @@ fn negotiated_response_arms(body: &NegotiatedBody, status: &TokenStream, with_he /// Emit the axum server interface: the `Api` trait, per-operation `IntoResponse` /// enums, the `Router` builder, and the internal handler functions. -fn service_items(service: &Service) -> Result> { - let mut items = Vec::new(); +pub(crate) fn server_items(service: &Service, handler_visibility: HandlerVisibility) -> Result { + let mut operations = Vec::with_capacity(service.operations.len()); for operation in &service.operations { + let mut extractors = Vec::new(); if let Some(headers) = &operation.headers { - items.push(emit_headers_extractor(headers)?); + extractors.push(emit_headers_extractor(headers)?); } if let Some(cookies) = &operation.cookies { - items.push(emit_cookies_extractor(cookies)?); + extractors.push(emit_cookies_extractor(cookies)?); } match &operation.request { - Some(RequestPayload::Multipart(multipart)) => items.push(emit_multipart_extractor(multipart)?), - Some(RequestPayload::Negotiated(request)) => items.push(emit_request_body_extractor(request)?), + Some(RequestPayload::Multipart(multipart)) => extractors.push(emit_multipart_extractor(multipart)?), + Some(RequestPayload::Negotiated(request)) => extractors.push(emit_request_body_extractor(request)?), Some(RequestPayload::Single(_)) | None => {} } + operations.push(ServerOperationItems { + extractors, + into_response: emit_into_response(operation)?, + handler: emit_handler(operation, handler_visibility)?, + }); } - items.push(emit_trait(service)?); - for operation in &service.operations { - items.push(emit_into_response(operation)?); - } - items.push(emit_router(service)); - for operation in &service.operations { - items.push(emit_handler(operation)?); - } - return Ok(items); + return Ok(ServerItems { + operations, + api_trait: emit_trait(service)?, + router: emit_router(service), + }); } /// Emit the `Api` trait, one async method per operation. @@ -398,7 +465,7 @@ fn emit_router(service: &Service) -> TokenStream { /// Emit an operation's internal handler: extract the typed inputs, call the /// `Api` method, and return its response (which is `IntoResponse`). -fn emit_handler(operation: &Operation) -> Result { +fn emit_handler(operation: &Operation, visibility: HandlerVisibility) -> Result { let handler = axum_handler_name(&operation.name).to_token(); let method = operation.name.to_token(); let response = operation.response_enum.to_token(); @@ -462,8 +529,9 @@ fn emit_handler(operation: &Operation) -> Result { None => {} } + let vis = visibility.to_tokens(); return Ok(quote! { - async fn #handler(#(#extractors),*) -> #response { + #vis async fn #handler(#(#extractors),*) -> #response { api.#method(#(#call_args),*).await } }); diff --git a/crates/oapi-codegen/src/emit/mod.rs b/crates/oapi-codegen/src/emit/mod.rs index 7dcc9f5..a2422d1 100644 --- a/crates/oapi-codegen/src/emit/mod.rs +++ b/crates/oapi-codegen/src/emit/mod.rs @@ -8,6 +8,7 @@ mod axum; mod constraints; mod models; mod operation; +mod package; mod reqwest; mod servers; mod usage; @@ -18,6 +19,7 @@ use proc_macro2::TokenStream; use quote::quote; use crate::emit::models::ModelDerives; +pub use crate::emit::package::emit_package; use crate::error::Error; use crate::error::Result; use crate::ir::Module; @@ -47,6 +49,24 @@ pub trait ClientEmitter { fn emit(&self, service: &Service) -> Result>; } +/// The marker literal, so [`GENERATED_MARKER`] and [`HEADER`] cannot drift +/// apart. A `const` cannot be concatenated into another, and `concat!` takes +/// literals only, so the one spelling has to live in a macro. +macro_rules! generated_marker { + () => { + "// Code generated by oapi-codegen-rust. DO NOT EDIT." + }; +} + +/// The first line of every generated file: the marker that says the generator +/// owns it. +/// +/// A package writes several files into a directory it manages, and reconciling +/// that directory means deleting the files a previous run left behind. This +/// marker is what distinguishes them from a file somebody wrote by hand, which +/// the generator refuses to touch. +pub const GENERATED_MARKER: &str = generated_marker!(); + /// Header prepended to every generated file: the do-not-edit marker, then a /// blanket clippy allowance. /// @@ -59,12 +79,20 @@ pub trait ClientEmitter { /// `dead_code` is a rustc lint, so the groups miss it. A generated file offers every /// type the specification declares, and a consumer uses the ones it needs. /// +/// `unused_imports` is a rustc lint too, and the split output needs it for the same +/// reason. The root file re-exports every module so each name stays where it was, and +/// a module imports every name its file could refer to. Neither depends on the +/// consumer reaching for all of them. +/// /// An inner attribute needs the file to be a module. `#[path = "..."] mod x;` reads /// a generated file, and `include!` cannot, because rustc rejects an inner attribute /// in a paste. -pub const HEADER: &str = "// Code generated by oapi-codegen-rust. DO NOT EDIT. +pub const HEADER: &str = concat!( + generated_marker!(), + " #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, @@ -72,7 +100,8 @@ pub const HEADER: &str = "// Code generated by oapi-codegen-rust. DO NOT EDIT. reason = \"generated code, not first-party source\" )] -"; +" +); /// Render a module of IR items into formatted Rust source. /// diff --git a/crates/oapi-codegen/src/emit/package.rs b/crates/oapi-codegen/src/emit/package.rs new file mode 100644 index 0000000..01caa0a --- /dev/null +++ b/crates/oapi-codegen/src/emit/package.rs @@ -0,0 +1,346 @@ +//! Emitting the [`crate::ir`] as a module tree. +//! +//! The root file at the configured output path declares each child module with +//! an explicit `#[path]` and re-exports it, so every generated name keeps the +//! position it had in the single-file layout. The explicit path matters: a root +//! mounted with `#[path = "generated/restapi.rs"] mod restapi;` resolves a bare +//! `mod models;` beside *itself* rather than under a `restapi/` directory, so +//! only the written-out path places the children where they are. That path also +//! resolves correctly when the root is a plain `mod restapi;` in `src/`, which +//! lets one emitted tree serve both mounting styles. +//! +//! No `mod.rs` is written anywhere. A directory module beside a file module of +//! the same name is the `E0761` ambiguity, and the explicit `#[path]` removes +//! any need for one. + +use proc_macro2::Ident; +use proc_macro2::TokenStream; +use quote::format_ident; +use quote::quote; + +use crate::emit::HEADER; +use crate::emit::Targets; +use crate::emit::axum; +use crate::emit::operation; +use crate::emit::render_body; +use crate::emit::reqwest; +use crate::emit::usage; +use crate::error::Result; +use crate::ir::Module; +use crate::ir::ServerUrls; +use crate::ir::Service; +use crate::naming::RustIdent; +use crate::naming::operations::axum_handler_name; +use crate::package::GeneratedFile; +use crate::package::GeneratedPackage; + +/// The module holding the component models. +const MODELS: &str = "models"; +/// The module holding the server-URL constants and builders. +const SERVER_URLS: &str = "server_urls"; +/// The module holding the per-operation input and response types. +const OPERATIONS: &str = "operations"; +/// The module holding the axum server interface. +const SERVER: &str = "server"; +/// The module holding the blocking `reqwest` client. +const CLIENT: &str = "client"; + +/// How many modules separate an operation's file from the package root. +const OPERATION_DEPTH: usize = 2; +/// How many modules separate a top-level module file from the package root. +const MODULE_DEPTH: usize = 1; + +/// Emit the module tree for a run that lowered a service. +/// +/// `stem` is the output file's stem, which names the companion directory the +/// children live in. +pub fn emit_package( + module: &Module, + service: &Service, + server_urls: Option<&ServerUrls>, + targets: Targets, + stem: &str, +) -> Result { + let derives = usage::model_derives(module, service, targets); + let foreign = usage::foreign_resolver(module); + let modules: Vec = service + .operations + .iter() + .map(|operation| return operation_module(&operation.name)) + .collect(); + + let mut package = Builder::new(stem); + + let models = super::module_items(module, &derives)?; + let has_models = !models.is_empty(); + package.add(MODELS, models)?; + package.add(SERVER_URLS, super::server_url_items(server_urls)?)?; + + let mut declarations = Vec::with_capacity(modules.len()); + for (operation, module) in service.operations.iter().zip(&modules) { + let imports = imports(OPERATION_DEPTH, has_models, false, TokenStream::new()); + let items = operation::emit_operation_types(operation, targets, &foreign)?; + package.add_child(OPERATIONS, &module.stem, imports, items)?; + declarations.push(reexport(OPERATIONS, module)); + } + package.add(OPERATIONS, block(declarations))?; + + if targets.server { + let items = server_items(service, &modules, has_models, &mut package)?; + package.add(SERVER, items)?; + } + if targets.client { + let items = client_items(service, &modules, has_models, &mut package)?; + package.add(CLIENT, items)?; + } + + return package.finish(); +} + +/// Emit the server operation files and return the items `server.rs` holds. +fn server_items( + service: &Service, + modules: &[OperationModule], + has_models: bool, + package: &mut Builder, +) -> Result> { + let items = axum::server_items(service, axum::HandlerVisibility::Parent)?; + let api = format_ident!("{}", axum::API_TRAIT_NAME); + let mut declarations = Vec::with_capacity(modules.len()); + for ((entry, operation), module) in items.operations.into_iter().zip(&service.operations).zip(modules) { + // The handler names the `Api` trait in its bound, and the router in the + // parent module names the handler. + let imports = imports(OPERATION_DEPTH, has_models, true, quote! { use super::#api; }); + let mut file = entry.extractors; + file.push(entry.into_response); + file.push(entry.handler); + package.add_child(SERVER, &module.stem, imports, file)?; + let declaration = declare(SERVER, module); + let ident = &module.ident; + let handler = axum_handler_name(&operation.name).to_token(); + declarations.push(quote! { + #declaration + use #ident::#handler; + }); + } + let mut file: Vec = imports(MODULE_DEPTH, has_models, !modules.is_empty(), TokenStream::new()) + .into_iter() + .collect(); + file.extend(block(declarations)); + file.push(items.api_trait); + file.push(items.router); + return Ok(file); +} + +/// Emit the client operation files and return the items `client.rs` holds. +fn client_items( + service: &Service, + modules: &[OperationModule], + has_models: bool, + package: &mut Builder, +) -> Result> { + let items = reqwest::client_items(service)?; + let client = format_ident!("{}", reqwest::CLIENT_STRUCT_NAME); + let error = format_ident!("{}", reqwest::CLIENT_ERROR_NAME); + let encode_set = format_ident!("{}", reqwest::ENCODE_SET_NAME); + let mut declarations = Vec::with_capacity(modules.len()); + for ((method, operation), module) in items + .operation_methods + .into_iter() + .zip(&service.operations) + .zip(modules) + { + // An inherent `impl` applies crate-wide, so the method reaches callers + // from its own module. Only the names it mentions have to be imported. + let mut extra = quote! { use super::{#client, #error}; }; + if !operation.path_params.is_empty() { + extra.extend(quote! { use super::#encode_set; }); + } + let imports = imports(OPERATION_DEPTH, has_models, true, extra); + let body = vec![quote! { + impl #client { + #method + } + }]; + package.add_child(CLIENT, &module.stem, imports, body)?; + declarations.push(declare(CLIENT, module)); + } + let shared = items.shared_methods; + let mut file = block(declarations); + file.push(items.error); + file.extend(items.encode_set); + file.push(items.client_struct); + file.push(quote! { + impl #client { + #(#shared)* + } + }); + return Ok(file); +} + +/// Collects the files of a package and the modules its root mounts. +struct Builder { + /// The output file's stem, which names the companion directory. + stem: String, + /// The finished child files. + files: Vec, + /// The top-level modules the root mounts, in emission order. + mounted: Vec<&'static str>, +} + +impl Builder { + /// Start an empty package for the given output stem. + fn new(stem: &str) -> Self { + return Self { + stem: stem.to_owned(), + files: Vec::new(), + mounted: Vec::new(), + }; + } + + /// Add a top-level module, skipping it when it holds no item. + /// + /// An empty module would leave the root re-exporting a module with nothing + /// public in it, which rustc rejects, so an absent module is the right + /// answer for a run that produces none of its items. + fn add(&mut self, name: &'static str, items: Vec) -> Result<()> { + if items.is_empty() { + return Ok(()); + } + let path = format!("{}/{name}.rs", self.stem); + self.files.push(GeneratedFile::new(path, render(&items)?)); + self.mounted.push(name); + return Ok(()); + } + + /// Add one operation's file under a top-level module. + fn add_child( + &mut self, + parent: &'static str, + stem: &str, + imports: Option, + items: Vec, + ) -> Result<()> { + let file: Vec = imports.into_iter().chain(items).collect(); + let path = format!("{}/{parent}/{stem}.rs", self.stem); + self.files.push(GeneratedFile::new(path, render(&file)?)); + return Ok(()); + } + + /// Render the root facade and hand back the finished package. + fn finish(mut self) -> Result { + let mounts = self.mounted.iter().map(|name| { + let ident = format_ident!("{name}"); + let path = format!("{}/{name}.rs", self.stem); + return quote! { + #[path = #path] + mod #ident; + pub use #ident::*; + }; + }); + let root = render(&[quote! { #(#mounts)* }])?; + self.files.sort_by(|left, right| return left.path().cmp(right.path())); + return Ok(GeneratedPackage::new(root, self.files)); + } +} + +/// Join related declarations into one rendered item, or nothing when there are +/// none. +/// +/// Items are rendered a blank line apart, which reads well between types and +/// badly between a run of one-line `mod` declarations. +fn block(declarations: Vec) -> Vec { + if declarations.is_empty() { + return Vec::new(); + } + return vec![quote! { #(#declarations)* }]; +} + +/// The file and module an operation gets. +/// +/// The two are tracked together because they can differ. An operation named +/// `mod` needs the file stem `mod_`, since `mod.rs` beside `operations.rs` is +/// the `E0761` ambiguity, while its module item stays `r#mod`. The explicit +/// `#[path]` ties the two back together. +struct OperationModule { + /// The file stem, without the `.rs`. + stem: String, + /// The identifier of the module item, raw when the name is a keyword. + ident: Ident, +} + +/// The file and module for the operation named `name`. +/// +/// Operation names are unique `snake_case` identifiers, so the stems collide +/// with nothing on any filesystem, including a case-insensitive one. +fn operation_module(name: &RustIdent) -> OperationModule { + let text = name.logical(); + let stem = if text == "mod" { + format!("{text}_") + } else { + text.to_owned() + }; + return OperationModule { + stem, + ident: name.to_token(), + }; +} + +/// The declaration and re-export a parent module writes for one child. +fn reexport(parent: &'static str, module: &OperationModule) -> TokenStream { + let declaration = declare(parent, module); + let ident = &module.ident; + return quote! { + #declaration + pub use #ident::*; + }; +} + +/// The `#[path]`-carrying `mod` item a parent module writes for one child. +fn declare(parent: &'static str, module: &OperationModule) -> TokenStream { + let path = format!("{parent}/{}.rs", module.stem); + let ident = &module.ident; + return quote! { + #[path = #path] + mod #ident; + }; +} + +/// The `use` items a generated module needs, or nothing when it needs none. +/// +/// `depth` is how many modules lie between the file and the package root. A file +/// imports every name it could refer to, whether or not it does: an operation +/// that names only primitives references no model, and the header's +/// `unused_imports` allowance covers that. +fn imports(depth: usize, models: bool, operations: bool, extra: TokenStream) -> Option { + if !models && !operations && extra.is_empty() { + return None; + } + let root = root_path(depth); + let models = models.then(|| { + let ident = format_ident!("{MODELS}"); + return quote! { use #root #ident::*; }; + }); + let operations = operations.then(|| { + let ident = format_ident!("{OPERATIONS}"); + return quote! { use #root #ident::*; }; + }); + return Some(quote! { + #models + #operations + #extra + }); +} + +/// The `super::` chain that reaches the package root from `depth` modules down. +fn root_path(depth: usize) -> TokenStream { + let hops = std::iter::repeat_n(quote! { super:: }, depth); + return quote! { #(#hops)* }; +} + +/// Render a file: the generated header, then the items one blank line apart. +fn render(items: &[TokenStream]) -> Result { + let mut out = String::from(HEADER); + out.push_str(&render_body(items)?); + return Ok(out); +} diff --git a/crates/oapi-codegen/src/emit/reqwest.rs b/crates/oapi-codegen/src/emit/reqwest.rs index b8c948e..1db9445 100644 --- a/crates/oapi-codegen/src/emit/reqwest.rs +++ b/crates/oapi-codegen/src/emit/reqwest.rs @@ -39,9 +39,47 @@ pub(crate) const CLIENT_STRUCT_NAME: &str = "Client"; /// component schema cannot collide with it. pub(crate) const CLIENT_ERROR_NAME: &str = "ClientError"; +/// Name of the percent-encoding set the client applies to path parameters. A +/// package puts it beside the `Client` struct, and each operation module that +/// has a path parameter imports it by this name. +pub(crate) const ENCODE_SET_NAME: &str = "PATH_PARAM_ENCODE_SET"; + impl crate::emit::ClientEmitter for ReqwestClient { fn emit(&self, service: &Service) -> Result> { - return client_items(service); + return Ok(client_items(service)?.into_flat()); + } +} + +/// The client items, split into the shared infrastructure and the per-operation +/// request methods, so a package can put each method in its own file. +pub(crate) struct ClientItems { + /// The `ClientError` enum and its trait impls. + pub error: TokenStream, + /// The percent-encoding set, emitted only when some operation has a path + /// parameter. + pub encode_set: Option, + /// The `Client` struct definition. + pub client_struct: TokenStream, + /// The constructors and credential setters, which belong to every operation. + pub shared_methods: Vec, + /// One request method per operation, in `service.operations` order. + pub operation_methods: Vec, +} + +impl ClientItems { + /// Flatten into the single-file item order, with every method in one + /// inherent `impl Client` block. + pub fn into_flat(self) -> Vec { + let mut items = vec![self.error]; + items.extend(self.encode_set); + items.push(self.client_struct); + let methods = self.shared_methods.into_iter().chain(self.operation_methods); + items.push(quote! { + impl Client { + #(#methods)* + } + }); + return items; } } @@ -78,22 +116,26 @@ fn ensure_supported(operation: &Operation, schemes: &[SecurityScheme]) -> Result /// Emit the client: the `ClientError` type, per-operation input/response types, /// the `Client` struct, and its inherent `impl` with one method per operation. -fn client_items(service: &Service) -> Result> { +pub(crate) fn client_items(service: &Service) -> Result { for operation in &service.operations { ensure_supported(operation, &service.security_schemes)?; } - let mut items = vec![client_error()]; - if service + let has_path_params = service .operations .iter() - .any(|operation| return !operation.path_params.is_empty()) - { - items.push(path_param_encode_set()); + .any(|operation| return !operation.path_params.is_empty()); + let mut operation_methods = Vec::with_capacity(service.operations.len()); + for operation in &service.operations { + operation_methods.push(emit_method(operation, &service.security_schemes)?); } - items.push(client_struct(&service.security_schemes)); - items.push(emit_client_impl(service)?); - return Ok(items); + return Ok(ClientItems { + error: client_error(), + encode_set: has_path_params.then(path_param_encode_set), + client_struct: client_struct(&service.security_schemes), + shared_methods: shared_methods(&service.security_schemes), + operation_methods, + }); } /// Emit the `ClientError` type shared by every client method. @@ -185,12 +227,11 @@ fn client_struct(schemes: &[SecurityScheme]) -> TokenStream { }; } -/// Emit the `impl Client` block: the constructors, the `with_` credential -/// setters, then one method per operation. -fn emit_client_impl(service: &Service) -> Result { - let schemes = &service.security_schemes; +/// The `impl Client` methods that belong to no single operation: the +/// constructors and the `with_` credential setters. +fn shared_methods(schemes: &[SecurityScheme]) -> Vec { let inits = credential_inits(schemes); - let mut methods = Vec::with_capacity(service.operations.len() + schemes.len() + 2); + let mut methods = Vec::with_capacity(schemes.len().saturating_add(2)); methods.push(quote! { /// Build a client targeting `base_url` with a default blocking /// `reqwest::blocking::Client`. @@ -206,17 +247,8 @@ fn emit_client_impl(service: &Service) -> Result { return Self { base_url: base_url.into(), http, #(#inits,)* }; } }); - for setter in credential_setters(schemes) { - methods.push(setter); - } - for operation in &service.operations { - methods.push(emit_method(operation, schemes)?); - } - return Ok(quote! { - impl Client { - #(#methods)* - } - }); + methods.extend(credential_setters(schemes)); + return methods; } /// The credential fields added to the `Client` struct, one per supported scheme. diff --git a/crates/oapi-codegen/src/error.rs b/crates/oapi-codegen/src/error.rs index 01bf224..9694283 100644 --- a/crates/oapi-codegen/src/error.rs +++ b/crates/oapi-codegen/src/error.rs @@ -76,6 +76,38 @@ pub enum Error { source: std::io::Error, }, + /// The companion directory beside the output file holds a file the + /// generator did not write. + /// + /// The generator owns that directory: it deletes the files an earlier run + /// left there and this run no longer produces. A file without the generated + /// marker is somebody's work, so the run stops rather than delete it. + UnownedOutput { + /// Path of the file the generator does not own. + path: String, + }, + + /// A generated file lies outside the directory the run owns. + /// + /// Every file of a package belongs under the companion directory, which is + /// the only place a run writes to and deletes from. + OutsideOutput { + /// The offending file path, relative to the root file's directory. + path: String, + /// The companion directory the file has to be under. + directory: String, + }, + + /// The output path cannot carry a companion directory beside it. + /// + /// A run with operations writes a root file plus a directory named after + /// its stem. An output path with no stem, or one whose stem equals the file + /// name, would put the directory and the root file at the same path. + UnsplittableOutput { + /// The output path that cannot be split. + path: String, + }, + /// The document declares an OpenAPI version the generator does not read. /// /// Only `3.0.x` is supported. A newer document is rejected and not read as a @@ -406,6 +438,21 @@ impl std::fmt::Display for Error { Error::ReadOutput { path, source } => { return write!(f, "failed to read output `{path}`: {source}"); } + Error::OutsideOutput { path, directory } => { + return write!(f, "generated file `{path}` lies outside `{directory}`"); + } + Error::UnsplittableOutput { path } => { + return write!( + f, + "output path `{path}` needs a file extension: a run with operations writes a directory beside the file, named after its stem" + ); + } + Error::UnownedOutput { path } => { + return write!( + f, + "`{path}` sits in the generated output directory but was not generated" + ); + } // The remedy is a hint, which the console prints under the message. // `Display` therefore states the problem only. Error::UnsupportedSpecVersion { document, version, .. } => { @@ -549,6 +596,9 @@ impl std::error::Error for Error { // It has no single `source`. `Display` shows the problems instead. Error::Validation { .. } | Error::Unimplemented(_) + | Error::UnownedOutput { .. } + | Error::UnsplittableOutput { .. } + | Error::OutsideOutput { .. } | Error::UnsupportedSpecVersion { .. } | Error::UnsupportedSpecKey { .. } | Error::UnsupportedContentType { .. } diff --git a/crates/oapi-codegen/src/lib.rs b/crates/oapi-codegen/src/lib.rs index d5a18e9..5bbff09 100644 --- a/crates/oapi-codegen/src/lib.rs +++ b/crates/oapi-codegen/src/lib.rs @@ -15,6 +15,7 @@ pub mod ir; pub mod loader; pub mod lower; pub mod naming; +pub mod package; use std::path::Path; @@ -22,17 +23,44 @@ pub use crate::config::Config; pub use crate::error::Error; pub use crate::error::Result; use crate::ir::Module; +use crate::ir::ServerUrls; +use crate::ir::Service; use crate::loader::Spec; +pub use crate::package::GeneratedFile; +pub use crate::package::GeneratedPackage; +pub use crate::package::PackageDrift; +pub use crate::package::check_package; +pub use crate::package::write_package; -/// Generate Rust from a spec file according to `configuration`, returning the source. +/// Everything one run lowers from a spec, ready for either output layout. +enum Lowered { + /// The run emits models, and optionally server-URL constants, only. + Models { + /// The component models to emit. + module: Module, + /// The server-URL items, when the feature is enabled. + server_urls: Option, + }, + /// The run emits a service: models, per-operation types, and at least one + /// of the server and client interfaces. + Service { + /// The component models the service references. + module: Module, + /// The lowered operations. + service: Service, + /// The server-URL items, when the feature is enabled. + server_urls: Option, + /// Which generator interfaces the configuration asked for. + targets: emit::Targets, + }, +} + +/// Load a spec and lower it according to `config`, stopping before emission. /// -/// Models are emitted when `generate.models` is set, or implicitly when the -/// server or client is generated (so referenced types are in scope). The axum -/// server interface is appended when `generate.std-http-server` is set. The -/// blocking `reqwest` client is appended when `generate.client` is set. Models, -/// per-operation types, and both generators are emitted flat at the crate root, -/// so server and client can share one file. -pub fn generate(spec_path: &Path, config: &Config) -> Result { +/// Both output layouts run exactly the same pipeline, so this holds every step +/// between loading and emitting: filtering, name resolution, lowering, pruning, +/// and the validation passes that reject a spec the emitter cannot express. +fn lower_spec(spec_path: &Path, config: &Config) -> Result { if config.generate.embedded_spec { return Err(Error::Unimplemented("embedded-spec".to_owned())); } @@ -50,7 +78,10 @@ pub fn generate(spec_path: &Path, config: &Config) -> Result { // report a collision between two schemas that it never looks at. This matches // `response-type-suffix`, which only a server or client run reads. if !(config.generate.models || want_server || want_client) { - return emit::emit_module(&Module::default(), server_urls.as_ref()); + return Ok(Lowered::Models { + module: Module::default(), + server_urls, + }); } // A set but useless suffix is an error, and not silently "unset". The // resolution checks it, so every caller of `type_renames` gets the check. @@ -88,7 +119,12 @@ pub fn generate(spec_path: &Path, config: &Config) -> Result { }; lower::check_type_name_collisions(&service, &module, &emit::reserved_type_names(targets))?; lower::check_prelude_shadowing(&module, targets)?; - return emit::emit_flat(&module, &service, server_urls.as_ref(), targets); + return Ok(Lowered::Service { + module, + service, + server_urls, + targets, + }); } // Models-only generation prunes nothing, so the module holds every schema and // every collision reports. @@ -96,7 +132,71 @@ pub fn generate(spec_path: &Path, config: &Config) -> Result { lower::check_duplicate_models(&module)?; lower::check_prelude_shadowing(&module, emit::Targets::default())?; lower::box_recursive_types(&mut module)?; - return emit::emit_module(&module, server_urls.as_ref()); + return Ok(Lowered::Models { module, server_urls }); +} + +/// Generate Rust from a spec file according to `configuration`, returning the source. +/// +/// Models are emitted when `generate.models` is set, or implicitly when the +/// server or client is generated (so referenced types are in scope). The axum +/// server interface is appended when `generate.std-http-server` is set. The +/// blocking `reqwest` client is appended when `generate.client` is set. Models, +/// per-operation types, and both generators are emitted flat at the crate root, +/// so server and client can share one file. +/// +/// Use [`generate_package`] for the layout the CLI writes, which splits the same +/// items across a module tree. +pub fn generate(spec_path: &Path, config: &Config) -> Result { + return match lower_spec(spec_path, config)? { + Lowered::Models { module, server_urls } => emit::emit_module(&module, server_urls.as_ref()), + Lowered::Service { + module, + service, + server_urls, + targets, + } => emit::emit_flat(&module, &service, server_urls.as_ref(), targets), + }; +} + +/// Generate Rust from a spec file according to `configuration`, returning every +/// file the run produces. +/// +/// A run that lowers operations splits its output across a module tree: the file +/// at `output_path` becomes a facade of re-exports, and a companion directory +/// named after that file's stem holds one module per concern (`models`, +/// `operations`, `server`, `client`, `server_urls`) with one file per operation +/// underneath. Every generated name stays reachable from the root file, so a +/// consumer that already mounts it needs no change. +/// +/// `output_path` is read for its file stem only, which names that companion +/// directory. Nothing is read from or written to disk here; see +/// [`write_package`] and [`check_package`]. +/// +/// A models-only run has no operations to split, so it produces the single file +/// it always has. +pub fn generate_package(spec_path: &Path, config: &Config, output_path: &Path) -> Result { + return match lower_spec(spec_path, config)? { + Lowered::Models { module, server_urls } => Ok(GeneratedPackage::new( + emit::emit_module(&module, server_urls.as_ref())?, + Vec::new(), + )), + Lowered::Service { + module, + service, + server_urls, + targets, + } => { + let stem = package::companion_of(output_path)? + .file_name() + .map(|stem| return stem.to_string_lossy().into_owned()) + .ok_or_else(|| { + return Error::UnsplittableOutput { + path: output_path.display().to_string(), + }; + })?; + emit::emit_package(&module, &service, server_urls.as_ref(), targets, &stem) + } + }; } /// Generate Rust from a spec file according to `configuration` and write it to diff --git a/crates/oapi-codegen/src/main.rs b/crates/oapi-codegen/src/main.rs index 752607c..6dd0973 100644 --- a/crates/oapi-codegen/src/main.rs +++ b/crates/oapi-codegen/src/main.rs @@ -14,8 +14,8 @@ use std::process::ExitCode; use clap::Parser; use oapi_codegen::Config; -use oapi_codegen::Drift; use oapi_codegen::Error; +use oapi_codegen::PackageDrift; use oapi_codegen::Result; use oapi_codegen::cli::Cli; use oapi_codegen::config::Generate; @@ -45,10 +45,10 @@ enum CliFailure { /// `--check` found the output file missing or out of date. Nothing was /// written, because the flag asks for a comparison only. Drift { - /// The output file that was compared. - output: PathBuf, - /// Whether the file is absent, as opposed to present and different. - absent: bool, + /// The file the comparison stopped at. + path: PathBuf, + /// What the comparison found there. + kind: console::DriftKind, }, } @@ -68,8 +68,8 @@ impl CliFailure { CliFailure::EmptyOutput { spec, stats, generate } => { console::report_empty_output(spec, stats, generate); } - CliFailure::Drift { output, absent } => { - console::report_drift(output, *absent); + CliFailure::Drift { path, kind } => { + console::report_drift(path, *kind); } } } @@ -114,7 +114,10 @@ fn run(cli: &Cli) -> std::result::Result<(), CliFailure> { }); let output = output.ok_or(CliFailure::NoOutput)?; - let code = oapi_codegen::generate(&cli.spec_file, &config)?; + let package = oapi_codegen::generate_package(&cli.spec_file, &config, &output)?; + // Both scans read the generated code as text, and splitting it across files + // must not change what either one concludes, so both read every file. + let code = package.combined_source(); if console::is_effectively_empty(&code) { let stats = spec_stats(&cli.spec_file, &config)?; return Err(CliFailure::EmptyOutput { @@ -127,22 +130,34 @@ fn run(cli: &Cli) -> std::result::Result<(), CliFailure> { if cli.check { // `--check` compares and writes nothing, so it reports no dependencies // either. A comparison adds no crate to the manifest. - match oapi_codegen::check_output(&output, &code)? { - Drift::None => { + match oapi_codegen::check_package(&output, &package)? { + PackageDrift::None => { console::report_check_passed(&output); return Ok(()); } - Drift::Absent => { - return Err(CliFailure::Drift { output, absent: true }); + PackageDrift::Absent(path) => { + return Err(CliFailure::Drift { + path, + kind: console::DriftKind::Absent, + }); } - Drift::Differs => { - return Err(CliFailure::Drift { output, absent: false }); + PackageDrift::Differs(path) => { + return Err(CliFailure::Drift { + path, + kind: console::DriftKind::Differs, + }); + } + PackageDrift::Stale(path) => { + return Err(CliFailure::Drift { + path, + kind: console::DriftKind::Stale, + }); } } } - oapi_codegen::write_output(&output, &code)?; - console::report_wrote(&output); + oapi_codegen::write_package(&output, &package)?; + console::report_wrote(&output, package.file_count()); let manifest = nearest_manifest(&output); let deps = oapi_codegen::deps::required_dependencies(&code); diff --git a/crates/oapi-codegen/src/package.rs b/crates/oapi-codegen/src/package.rs new file mode 100644 index 0000000..c029975 --- /dev/null +++ b/crates/oapi-codegen/src/package.rs @@ -0,0 +1,375 @@ +//! The generated output as a set of files, and the file operations over it. +//! +//! A run that emits operations produces a small module tree instead of one +//! file: a root file the consumer mounts, and a companion directory beside it +//! holding one module per concern. The root file keeps its path, so a consumer +//! that already mounts it needs no change, and the root re-exports every child, +//! so every generated name stays where it was. + +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use crate::emit::GENERATED_MARKER; +use crate::emit::HEADER; +use crate::error::Error; +use crate::error::Result; + +/// One file of a [`GeneratedPackage`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GeneratedFile { + /// Where the file goes, relative to the directory holding the root file. + path: PathBuf, + /// The complete source, header included. + source: String, +} + +impl GeneratedFile { + /// Build a file from its relative path and its complete source. + pub fn new(path: impl Into, source: impl Into) -> Self { + return Self { + path: path.into(), + source: source.into(), + }; + } + + /// Where the file goes, relative to the directory holding the root file. + pub fn path(&self) -> &Path { + return &self.path; + } + + /// The complete source, header included. + pub fn source(&self) -> &str { + return &self.source; + } + + /// The source with the generated-file header removed. + fn body(&self) -> &str { + return self.source.strip_prefix(HEADER).unwrap_or(&self.source); + } +} + +/// Everything one generator run produces. +/// +/// A models-only run has no children and behaves exactly like the single file +/// it has always written. A run with operations adds the children, and the root +/// becomes a facade of `#[path]` module declarations and re-exports. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GeneratedPackage { + /// The source of the file at the configured output path. + root: String, + /// The companion module files, relative to the root file's directory. + children: Vec, +} + +impl GeneratedPackage { + /// Build a package from the root source and its companion files. + pub fn new(root: impl Into, children: Vec) -> Self { + return Self { + root: root.into(), + children, + }; + } + + /// The source of the file at the configured output path. + pub fn root_source(&self) -> &str { + return &self.root; + } + + /// The companion module files, relative to the root file's directory. + pub fn children(&self) -> &[GeneratedFile] { + return &self.children; + } + + /// How many files a write produces. + pub fn file_count(&self) -> usize { + return self.children.len().saturating_add(1); + } + + /// Every file's body behind one header, for a scan that has to see all of + /// the generated code at once. + /// + /// Dependency detection and the empty-output check both read the code as + /// text. Splitting the output across files must not change what either one + /// concludes, so both read this instead of any single file. + pub fn combined_source(&self) -> String { + let mut combined = String::from(HEADER); + combined.push_str(self.root.strip_prefix(HEADER).unwrap_or(&self.root)); + for child in &self.children { + combined.push_str(child.body()); + } + return combined; + } +} + +/// What a comparison of a generated package against the files on disk found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PackageDrift { + /// Every file on disk holds the generated code, and no other file does. + None, + /// A file generation would write does not exist. + Absent(PathBuf), + /// A file exists and holds different content. + Differs(PathBuf), + /// A file an earlier run wrote is still there, and this run does not + /// produce it. + Stale(PathBuf), +} + +/// The companion directory beside `output_path`, named after its file stem. +/// +/// The root file declares each child with an explicit `#[path]` relative to its +/// own directory, so this is where those declarations point. +/// +/// Gives `None` when the path has no file stem. Use [`companion_of`] where the +/// directory has to be usable: an extensionless path yields the output file +/// itself, which cannot hold the children. +fn companion_directory(output_path: &Path) -> Option { + let stem = output_path.file_stem()?; + let parent = output_path.parent().unwrap_or_else(|| return Path::new("")); + return Some(parent.join(stem)); +} + +/// Write every file of `package`, then remove the files an earlier run left in +/// the companion directory that this run does not produce. +/// +/// The companion directory is audited before anything is written, so a run that +/// refuses leaves every file it would have replaced intact. +/// +/// # Errors +/// +/// Returns [`Error::WriteOutput`] when a file cannot be written, and +/// [`Error::UnownedOutput`] when the companion directory holds a file the +/// generator did not write. Refusing is deliberate: the directory belongs to +/// the generator, and deleting a hand-written file that landed in it would lose +/// work. +pub fn write_package(output_path: &Path, package: &GeneratedPackage) -> Result<()> { + let stale = audit(output_path, package)?; + + crate::write_output(output_path, package.root_source())?; + let parent = output_path.parent().unwrap_or_else(|| return Path::new("")); + for child in package.children() { + crate::write_output(&parent.join(child.path()), child.source())?; + } + for path in stale { + std::fs::remove_file(&path).map_err(|source| { + return Error::WriteOutput { + path: path.display().to_string(), + source, + }; + })?; + } + if let Some(directory) = companion_directory(output_path) { + remove_empty_directories(&directory); + } + return Ok(()); +} + +/// Compare `package` with the files on disk and report the first difference. +/// +/// # Errors +/// +/// Returns [`Error::ReadOutput`] when a file exists and cannot be read, and +/// [`Error::UnownedOutput`] when the companion directory holds a file the +/// generator did not write. +pub fn check_package(output_path: &Path, package: &GeneratedPackage) -> Result { + // The audit comes first so that a file the generator does not own is + // reported as such, rather than as drift against what would replace it. + let stale = audit(output_path, package)?; + // The root comes next, because it is the file a consumer mounts and the + // one a reader looks at first. + if let Some(drift) = compare(output_path, package.root_source())? { + return Ok(drift); + } + let parent = output_path.parent().unwrap_or_else(|| return Path::new("")); + for child in package.children() { + if let Some(drift) = compare(&parent.join(child.path()), child.source())? { + return Ok(drift); + } + } + if let Some(path) = stale.into_iter().next() { + return Ok(PackageDrift::Stale(path)); + } + return Ok(PackageDrift::None); +} + +/// Compare one file, giving `None` when it already holds `source`. +fn compare(path: &Path, source: &str) -> Result> { + return match crate::check_output(path, source)? { + crate::Drift::None => Ok(None), + crate::Drift::Absent => Ok(Some(PackageDrift::Absent(path.to_path_buf()))), + crate::Drift::Differs => Ok(Some(PackageDrift::Differs(path.to_path_buf()))), + }; +} + +/// The companion directory a package with children needs beside `output_path`. +/// +/// # Errors +/// +/// Returns [`Error::UnsplittableOutput`] when the directory would be the output +/// file itself, which is what an output path with no extension asks for. +pub(crate) fn companion_of(output_path: &Path) -> Result { + return companion_directory(output_path) + .filter(|directory| return directory != output_path) + .ok_or_else(|| { + return Error::UnsplittableOutput { + path: output_path.display().to_string(), + }; + }); +} + +/// Check every existing file in the companion directory and return the +/// generated ones `package` does not produce, sorted so a report names the same +/// file on every run. +/// +/// Auditing the whole directory up front is what lets a write be safe. Every +/// file the generator would replace or delete is inspected first, so a run that +/// meets somebody else's work stops before touching anything. +fn audit(output_path: &Path, package: &GeneratedPackage) -> Result> { + // A run without children still has to clear the companion directory an + // earlier run left behind, so only a path that cannot have one is skipped. + let directory = match companion_of(output_path) { + Ok(directory) => directory, + Err(_) if package.children().is_empty() => return Ok(Vec::new()), + Err(error) => return Err(error), + }; + let parent = output_path.parent().unwrap_or_else(|| return Path::new("")); + for child in package.children() { + let path = child.path(); + // Every step below writes or deletes under the companion directory and + // trusts that each child lands there. The emitter always builds such a + // path, but the package types are public and can be built by hand. + let contained = path + .components() + .all(|component| return matches!(component, Component::Normal(_))) + && parent.join(path).starts_with(&directory); + if !contained { + return Err(Error::OutsideOutput { + path: path.display().to_string(), + directory: directory.display().to_string(), + }); + } + } + match std::fs::symlink_metadata(&directory) { + Ok(metadata) if metadata.is_dir() => {} + // A companion directory that is a file or a link is not the generator's + // work, and the run must not write children through it. + Ok(_) => { + return Err(Error::UnownedOutput { + path: directory.display().to_string(), + }); + } + Err(source) if source.kind() == std::io::ErrorKind::NotFound => { + return Ok(Vec::new()); + } + Err(source) => { + return Err(Error::ReadOutput { + path: directory.display().to_string(), + source, + }); + } + } + let expected: Vec = package + .children() + .iter() + .map(|child| return parent.join(child.path())) + .collect(); + let mut stale = Vec::new(); + collect_stale(&directory, &expected, &mut stale)?; + stale.sort(); + return Ok(stale); +} + +/// Walk `directory`, rejecting every file the generator does not own and adding +/// the generated ones `expected` does not list. +fn collect_stale(directory: &Path, expected: &[PathBuf], stale: &mut Vec) -> Result<()> { + let entries = std::fs::read_dir(directory).map_err(|source| { + return Error::ReadOutput { + path: directory.display().to_string(), + source, + }; + })?; + for entry in entries { + let entry = entry.map_err(|source| { + return Error::ReadOutput { + path: directory.display().to_string(), + source, + }; + })?; + let path = entry.path(); + let kind = entry.file_type().map_err(|source| { + return Error::ReadOutput { + path: path.display().to_string(), + source, + }; + })?; + // The generator writes no link. Following one would take the walk out of + // the directory this run owns, so a link is somebody's work whatever it + // points at. + if kind.is_symlink() { + return Err(Error::UnownedOutput { + path: path.display().to_string(), + }); + } + if kind.is_dir() { + collect_stale(&path, expected, stale)?; + continue; + } + // Reading a device or a pipe can block for ever, and the generator + // writes neither, so anything but a regular file is somebody else's. + // + // Ownership is settled before the expected set is consulted, so a + // hand-written file sitting where a generated one belongs stops the run + // instead of being overwritten. + if !kind.is_file() || !is_generated(&path)? { + return Err(Error::UnownedOutput { + path: path.display().to_string(), + }); + } + if expected.iter().any(|candidate| return *candidate == path) { + continue; + } + stale.push(path); + } + return Ok(()); +} + +/// Whether `path` carries the marker every generated file opens with. +/// +/// Only the marker is read. The file may be anything at all, and a run must not +/// have to hold it in memory to decide it is not the generator's. +fn is_generated(path: &Path) -> Result { + let read = |source| { + return Error::ReadOutput { + path: path.display().to_string(), + source, + }; + }; + let marker = GENERATED_MARKER.as_bytes(); + let mut file = std::fs::File::open(path).map_err(read)?; + let mut opening = vec![0_u8; marker.len()]; + return match std::io::Read::read_exact(&mut file, &mut opening) { + Ok(()) => Ok(opening == marker), + // A file shorter than the marker cannot carry it. + Err(source) if source.kind() == std::io::ErrorKind::UnexpectedEof => Ok(false), + Err(source) => Err(read(source)), + }; +} + +/// Remove `directory` and every directory under it that holds nothing. +/// +/// A removal that fails leaves an empty directory behind, which costs nothing +/// and breaks no later run, so this reports no error. +fn remove_empty_directories(directory: &Path) { + let Ok(entries) = std::fs::read_dir(directory) else { + return; + }; + for entry in entries.flatten() { + // `file_type` does not follow links, so the walk stays inside the + // directory this run owns. + if entry.file_type().map(|kind| return kind.is_dir()).unwrap_or(false) { + remove_empty_directories(&entry.path()); + } + } + let _ = std::fs::remove_dir(directory); +} diff --git a/crates/oapi-codegen/tests/check_mode.rs b/crates/oapi-codegen/tests/check_mode.rs index 69f50ba..7d907bc 100644 --- a/crates/oapi-codegen/tests/check_mode.rs +++ b/crates/oapi-codegen/tests/check_mode.rs @@ -57,6 +57,44 @@ generate: models: true "; +/// A spec with one operation, so its output spans several files. +const PACKAGE_SPEC: &str = "\ +openapi: 3.0.3 +info: + title: Demo + version: 1.0.0 +paths: + /widgets: + get: + operationId: listWidgets + responses: + '200': + description: The widgets + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + required: [name] + properties: + name: + type: string +"; + +/// A configuration that generates a server, which splits the output into a root +/// file and a companion directory. +const PACKAGE_CONFIG: &str = "\ +package: demo +generate: + models: true + std-http-server: true +"; + /// A directory under the temp directory that goes away with the test. struct TestDir { path: PathBuf, @@ -239,3 +277,113 @@ fn check_reports_a_spec_error_and_not_drift() { "a spec error must not report as drift: {report}" ); } + +/// Turn `dir` into a run that generates a server, whose output spans a root file +/// and a companion directory beside it. +fn make_package_run(dir: &TestDir) { + dir.write("config.yaml", PACKAGE_CONFIG); + dir.write("spec.yaml", PACKAGE_SPEC); +} + +#[test] +fn a_run_that_splits_its_output_writes_and_checks_every_file() { + let dir = TestDir::new("package-roundtrip"); + make_package_run(&dir); + + let output = dir.run(false); + assert_eq!(code(&output), SUCCESS, "stderr: {}", stderr(&output)); + assert!( + stderr(&output).contains("modules beside it"), + "the report must name the companion modules: {}", + stderr(&output) + ); + + let companion = dir.join("output"); + assert!(companion.is_dir(), "the companion directory should exist"); + for file in ["models.rs", "operations.rs", "server.rs"] { + assert!(companion.join(file).is_file(), "`{file}` should exist"); + } + + let output = dir.run(true); + assert_eq!(code(&output), SUCCESS, "stderr: {}", stderr(&output)); +} + +#[test] +fn check_fails_when_a_companion_file_is_missing_or_edited() { + let dir = TestDir::new("package-child-drift"); + make_package_run(&dir); + assert_eq!(code(&dir.run(false)), SUCCESS, "the write must succeed first"); + + let child = dir.join("output").join("models.rs"); + let before = read(&child); + std::fs::remove_file(&child).unwrap_or_else(|err| panic!("removing `{}` failed: {err}", child.display())); + let output = dir.run(true); + assert_eq!(code(&output), FAILURE, "stderr: {}", stderr(&output)); + assert!( + stderr(&output).contains("does not exist"), + "the report must name the missing file: {}", + stderr(&output) + ); + + dir.write("output/models.rs", &format!("{before}// edited\n")); + let output = dir.run(true); + assert_eq!(code(&output), FAILURE, "stderr: {}", stderr(&output)); + assert!( + stderr(&output).contains("out of date"), + "the report must name the edited file: {}", + stderr(&output) + ); + assert!( + read(&child).contains("// edited"), + "`--check` must not rewrite a companion file" + ); +} + +#[test] +fn a_companion_file_the_run_no_longer_produces_fails_a_check_and_a_run_removes_it() { + let dir = TestDir::new("package-stale"); + make_package_run(&dir); + assert_eq!(code(&dir.run(false)), SUCCESS, "the write must succeed first"); + + // What an operation rename leaves behind: a file from an earlier run that + // still carries the generator's marker and that nothing declares any more. + dir.write( + "output/operations/removed_widget.rs", + "// Code generated by oapi-codegen-rust. DO NOT EDIT.\n", + ); + let stale = dir.join("output").join("operations").join("removed_widget.rs"); + + let output = dir.run(true); + assert_eq!(code(&output), FAILURE, "stderr: {}", stderr(&output)); + assert!( + stderr(&output).contains("left over from an earlier run"), + "the report must say the file is left over: {}", + stderr(&output) + ); + assert!(stale.is_file(), "`--check` must not remove it"); + + assert_eq!(code(&dir.run(false)), SUCCESS, "the write must succeed"); + assert!(!stale.exists(), "a run must remove it"); + assert_eq!(code(&dir.run(true)), SUCCESS, "the check must pass afterwards"); +} + +#[test] +fn a_file_the_generator_did_not_write_stops_the_run() { + let dir = TestDir::new("package-unowned"); + make_package_run(&dir); + assert_eq!(code(&dir.run(false)), SUCCESS, "the write must succeed first"); + + dir.write("output/notes.rs", "// mine, not the generator's\n"); + let unowned = dir.join("output").join("notes.rs"); + + for check in [true, false] { + let output = dir.run(check); + assert_eq!(code(&output), FAILURE, "stderr: {}", stderr(&output)); + assert!( + stderr(&output).contains("notes.rs"), + "the report must name the file it refuses: {}", + stderr(&output) + ); + assert!(unowned.is_file(), "the generator must not delete it"); + } +} diff --git a/crates/oapi-codegen/tests/coverage.rs b/crates/oapi-codegen/tests/coverage.rs index 57e4429..e614917 100644 --- a/crates/oapi-codegen/tests/coverage.rs +++ b/crates/oapi-codegen/tests/coverage.rs @@ -600,6 +600,7 @@ const CLIENT_UNSUPPORTED_FIXTURES: &[&str] = &[ /// flat crate-root layout in which the server and client share one file and the /// same per-operation types alongside the component models. const COMBINED_FIXTURES: &[&str] = &[ + "combined_keyword_operations", "combined_prelude_value_names", "combined_server_client", "combined_response_name_collision", @@ -1274,6 +1275,78 @@ macro_rules! combined_generated_tests { } combined_generated_tests!( + combined_keyword_operations, + combined_prelude_value_names, + combined_server_client, + combined_response_name_collision, + combined_x_rust_derive, +); + +/// The prefix a package golden's root file and companion directory carry, which +/// keeps them apart from the flat golden of the same fixture. +const PACKAGE_PREFIX: &str = "package_"; + +/// Regenerate `stem`'s combined output as a package and assert every file +/// matches its committed counterpart. +/// +/// The flat goldens cover what the emitter produces. These cover how it splits +/// that across files: the module declarations, the imports each file needs, and +/// the visibility a name has to carry to reach the module that uses it. Only +/// `tests/generated.rs`, which mounts the root file, can prove the tree compiles. +/// +/// The comparison goes through the same [`oapi_codegen::check_package`] a +/// consumer's `--check` runs, so a leftover file from an earlier shape of the +/// output fails here too. +/// +/// Refresh the committed files after an intentional change with +/// `make update-generated` +/// (`UPDATE_GENERATED=1 cargo test -p oapi-codegen --test coverage`). +fn assert_package_generated_matches(stem: &str) { + let dir = tests_dir(); + let fixture = dir.join("fixtures").join(format!("{stem}.yaml")); + let root = dir.join("generated").join(format!("{PACKAGE_PREFIX}{stem}.rs")); + + let package = oapi_codegen::generate_package(&fixture, &combined_config_for(stem), &root).unwrap_or_else(|err| { + panic!("generating package `{stem}` failed: {err}"); + }); + + if std::env::var_os("UPDATE_GENERATED").is_some() { + oapi_codegen::write_package(&root, &package).unwrap_or_else(|err| { + panic!("writing package `{stem}` failed: {err}"); + }); + return; + } + + let drift = oapi_codegen::check_package(&root, &package).unwrap_or_else(|err| { + panic!("comparing package `{stem}` failed (run `make update-generated`): {err}"); + }); + assert_eq!( + drift, + oapi_codegen::PackageDrift::None, + "generated package for `{stem}` drifted from tests/generated/{PACKAGE_PREFIX}{stem}/; \ + run `make update-generated` if this change is intentional", + ); +} + +/// Emit one `#[test]` per combined fixture for the package layout. +/// +/// The tests live in their own module, so each keeps the fixture's name without +/// colliding with the flat test of the same name. +macro_rules! package_generated_tests { + ($($stem:ident),+ $(,)?) => { + mod package_layout { + $( + #[test] + fn $stem() { + super::assert_package_generated_matches(stringify!($stem)); + } + )+ + } + }; +} + +package_generated_tests!( + combined_keyword_operations, combined_prelude_value_names, combined_server_client, combined_response_name_collision, diff --git a/crates/oapi-codegen/tests/fixtures/combined_keyword_operations.yaml b/crates/oapi-codegen/tests/fixtures/combined_keyword_operations.yaml new file mode 100644 index 0000000..a921436 --- /dev/null +++ b/crates/oapi-codegen/tests/fixtures/combined_keyword_operations.yaml @@ -0,0 +1,70 @@ +openapi: 3.0.3 +info: + title: Keyword Operations + version: 1.0.0 +servers: + - url: https://api.example.com +# Operation IDs that lower onto Rust keywords. Each one names a module in the +# split layout, so the module item has to carry the identifier's raw form +# (`r#type`) while its file takes a name no directory module can claim. +paths: + /type: + get: + operationId: type + responses: + "200": + description: A widget + content: + application/json: + schema: + $ref: "#/components/schemas/Widget" + /mod: + get: + operationId: mod + responses: + "200": + description: A widget + content: + application/json: + schema: + $ref: "#/components/schemas/Widget" + /self: + get: + operationId: self + responses: + "200": + description: A widget + content: + application/json: + schema: + $ref: "#/components/schemas/Widget" + /match/{id}: + post: + operationId: match + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/Widget" + responses: + "200": + description: A widget + content: + application/json: + schema: + $ref: "#/components/schemas/Widget" +components: + schemas: + Widget: + type: object + required: [name] + properties: + name: + type: string diff --git a/crates/oapi-codegen/tests/generated.rs b/crates/oapi-codegen/tests/generated.rs index ac625dc..df3fcda 100644 --- a/crates/oapi-codegen/tests/generated.rs +++ b/crates/oapi-codegen/tests/generated.rs @@ -30,6 +30,8 @@ mod generated { pub mod client_negotiated_response; #[path = "client_widgets.rs"] pub mod client_widgets; + #[path = "combined_keyword_operations.rs"] + pub mod combined_keyword_operations; #[path = "combined_prelude_value_names.rs"] pub mod combined_prelude_value_names; #[path = "combined_response_name_collision.rs"] @@ -140,6 +142,22 @@ mod generated { pub mod type_name_collisions; #[path = "value_constraints.rs"] pub mod value_constraints; + + // The same fixtures emitted as a module tree. Mounting the root file pulls + // in every child, which is what proves the split output resolves: the + // `#[path]` declarations find their files, each module imports the names it + // uses, and every name a sibling module needs is visible enough to reach it. + // The flat modules above cannot show any of that. + #[path = "package_combined_keyword_operations.rs"] + pub mod package_combined_keyword_operations; + #[path = "package_combined_prelude_value_names.rs"] + pub mod package_combined_prelude_value_names; + #[path = "package_combined_response_name_collision.rs"] + pub mod package_combined_response_name_collision; + #[path = "package_combined_server_client.rs"] + pub mod package_combined_server_client; + #[path = "package_combined_x_rust_derive.rs"] + pub mod package_combined_x_rust_derive; } /// Stand-in for the foreign types the `ext_x_rust_derive` fixture points its diff --git a/crates/oapi-codegen/tests/generated/allof_merge.rs b/crates/oapi-codegen/tests/generated/allof_merge.rs index 3d1c5bc..35a6209 100644 --- a/crates/oapi-codegen/tests/generated/allof_merge.rs +++ b/crates/oapi-codegen/tests/generated/allof_merge.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/anyof_untagged.rs b/crates/oapi-codegen/tests/generated/anyof_untagged.rs index 7c4832c..b0de9aa 100644 --- a/crates/oapi-codegen/tests/generated/anyof_untagged.rs +++ b/crates/oapi-codegen/tests/generated/anyof_untagged.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/array_types.rs b/crates/oapi-codegen/tests/generated/array_types.rs index 6865b43..e588e21 100644 --- a/crates/oapi-codegen/tests/generated/array_types.rs +++ b/crates/oapi-codegen/tests/generated/array_types.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/client_auth.rs b/crates/oapi-codegen/tests/generated/client_auth.rs index 6966c6b..71913cf 100644 --- a/crates/oapi-codegen/tests/generated/client_auth.rs +++ b/crates/oapi-codegen/tests/generated/client_auth.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/client_form_response.rs b/crates/oapi-codegen/tests/generated/client_form_response.rs index 11a5e2c..4a306f0 100644 --- a/crates/oapi-codegen/tests/generated/client_form_response.rs +++ b/crates/oapi-codegen/tests/generated/client_form_response.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/client_multipart_request.rs b/crates/oapi-codegen/tests/generated/client_multipart_request.rs index f2042b1..c30260b 100644 --- a/crates/oapi-codegen/tests/generated/client_multipart_request.rs +++ b/crates/oapi-codegen/tests/generated/client_multipart_request.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/client_negotiated_request.rs b/crates/oapi-codegen/tests/generated/client_negotiated_request.rs index efc97c7..dac8bce 100644 --- a/crates/oapi-codegen/tests/generated/client_negotiated_request.rs +++ b/crates/oapi-codegen/tests/generated/client_negotiated_request.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/client_negotiated_response.rs b/crates/oapi-codegen/tests/generated/client_negotiated_response.rs index 8dda815..185d3dc 100644 --- a/crates/oapi-codegen/tests/generated/client_negotiated_response.rs +++ b/crates/oapi-codegen/tests/generated/client_negotiated_response.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/client_widgets.rs b/crates/oapi-codegen/tests/generated/client_widgets.rs index 51df645..367bbaa 100644 --- a/crates/oapi-codegen/tests/generated/client_widgets.rs +++ b/crates/oapi-codegen/tests/generated/client_widgets.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/combined_keyword_operations.rs b/crates/oapi-codegen/tests/generated/combined_keyword_operations.rs new file mode 100644 index 0000000..a660883 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/combined_keyword_operations.rs @@ -0,0 +1,287 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Widget { + pub name: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum TypeResponse { + /// A widget + Ok(Widget), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ModResponse { + /// A widget + Ok(Widget), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SelfResponse { + /// A widget + Ok(Widget), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MatchResponse { + /// A widget + Ok(Widget), +} + +/// Server behaviour: implement one method per operation. +pub trait Api: Clone + Send + Sync + 'static { + fn r#type(&self) -> impl std::future::Future + Send; + fn r#mod(&self) -> impl std::future::Future + Send; + fn self_(&self) -> impl std::future::Future + Send; + fn r#match( + &self, + id: String, + body: Widget, + ) -> impl std::future::Future + Send; +} + +impl axum::response::IntoResponse for TypeResponse { + fn into_response(self) -> axum::response::Response { + match self { + TypeResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +impl axum::response::IntoResponse for ModResponse { + fn into_response(self) -> axum::response::Response { + match self { + ModResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +impl axum::response::IntoResponse for SelfResponse { + fn into_response(self) -> axum::response::Response { + match self { + SelfResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +impl axum::response::IntoResponse for MatchResponse { + fn into_response(self) -> axum::response::Response { + match self { + MatchResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +/// Build an axum `Router` that dispatches each route to `api`. +pub fn router(api: T) -> axum::Router { + axum::Router::new() + .route("/type", axum::routing::get(type_handler::)) + .route("/mod", axum::routing::get(mod_handler::)) + .route("/self", axum::routing::get(self_handler::)) + .route("/match/{id}", axum::routing::post(match_handler::)) + .with_state(api) +} + +async fn type_handler( + axum::extract::State(api): axum::extract::State, +) -> TypeResponse { + api.r#type().await +} + +async fn mod_handler( + axum::extract::State(api): axum::extract::State, +) -> ModResponse { + api.r#mod().await +} + +async fn self_handler( + axum::extract::State(api): axum::extract::State, +) -> SelfResponse { + api.self_().await +} + +async fn match_handler( + axum::extract::State(api): axum::extract::State, + axum::extract::Path(id): axum::extract::Path, + axum::Json(body): axum::Json, +) -> MatchResponse { + api.r#match(id, body).await +} + +/// 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), + /// The response cannot be decoded: a body that failed to + /// deserialize (for example malformed form-urlencoded content), or a + /// required response header that was missing or unparsable. + 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: {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'~'); + +/// A blocking HTTP client for the API. +/// +/// `base_url` is used as a prefix for every request path and must not +/// carry a trailing slash (for example `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` (for example preconfigured with timeouts). + pub fn with_client( + base_url: impl Into, + http: reqwest::blocking::Client, + ) -> Self { + return Self { + base_url: base_url.into(), + http, + }; + } + pub fn r#type(&self) -> Result { + let url = format!("{}/type", self.base_url); + let response = self.http.request(reqwest::Method::GET, url).send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Widget = response.json()?; + return Ok(TypeResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } + pub fn r#mod(&self) -> Result { + let url = format!("{}/mod", self.base_url); + let response = self.http.request(reqwest::Method::GET, url).send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Widget = response.json()?; + return Ok(ModResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } + pub fn self_(&self) -> Result { + let url = format!("{}/self", self.base_url); + let response = self.http.request(reqwest::Method::GET, url).send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Widget = response.json()?; + return Ok(SelfResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } + pub fn r#match( + &self, + id: String, + body: Widget, + ) -> Result { + let url = format!( + "{}/match/{}", 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); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Widget = response.json()?; + return Ok(MatchResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/combined_prelude_value_names.rs b/crates/oapi-codegen/tests/generated/combined_prelude_value_names.rs index b8c1530..b20a2e6 100644 --- a/crates/oapi-codegen/tests/generated/combined_prelude_value_names.rs +++ b/crates/oapi-codegen/tests/generated/combined_prelude_value_names.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/combined_response_name_collision.rs b/crates/oapi-codegen/tests/generated/combined_response_name_collision.rs index 3610ae1..2780507 100644 --- a/crates/oapi-codegen/tests/generated/combined_response_name_collision.rs +++ b/crates/oapi-codegen/tests/generated/combined_response_name_collision.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/combined_server_client.rs b/crates/oapi-codegen/tests/generated/combined_server_client.rs index d3e2b2f..7d8f26f 100644 --- a/crates/oapi-codegen/tests/generated/combined_server_client.rs +++ b/crates/oapi-codegen/tests/generated/combined_server_client.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/combined_x_rust_derive.rs b/crates/oapi-codegen/tests/generated/combined_x_rust_derive.rs index b0f3b24..f4d5cfb 100644 --- a/crates/oapi-codegen/tests/generated/combined_x_rust_derive.rs +++ b/crates/oapi-codegen/tests/generated/combined_x_rust_derive.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/compose_shared.rs b/crates/oapi-codegen/tests/generated/compose_shared.rs index fba55ad..185105c 100644 --- a/crates/oapi-codegen/tests/generated/compose_shared.rs +++ b/crates/oapi-codegen/tests/generated/compose_shared.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/ext_vendor_extensions.rs b/crates/oapi-codegen/tests/generated/ext_vendor_extensions.rs index 0e637a7..dfc3ac3 100644 --- a/crates/oapi-codegen/tests/generated/ext_vendor_extensions.rs +++ b/crates/oapi-codegen/tests/generated/ext_vendor_extensions.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/ext_x_rust_derive.rs b/crates/oapi-codegen/tests/generated/ext_x_rust_derive.rs index aa8f6b3..0f8023d 100644 --- a/crates/oapi-codegen/tests/generated/ext_x_rust_derive.rs +++ b/crates/oapi-codegen/tests/generated/ext_x_rust_derive.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/ext_x_rust_type.rs b/crates/oapi-codegen/tests/generated/ext_x_rust_type.rs index 7c6ae6c..e680724 100644 --- a/crates/oapi-codegen/tests/generated/ext_x_rust_type.rs +++ b/crates/oapi-codegen/tests/generated/ext_x_rust_type.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/freeform_any.rs b/crates/oapi-codegen/tests/generated/freeform_any.rs index 17088e9..a65d18b 100644 --- a/crates/oapi-codegen/tests/generated/freeform_any.rs +++ b/crates/oapi-codegen/tests/generated/freeform_any.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/integer_enum.rs b/crates/oapi-codegen/tests/generated/integer_enum.rs index c5856ba..ee733c2 100644 --- a/crates/oapi-codegen/tests/generated/integer_enum.rs +++ b/crates/oapi-codegen/tests/generated/integer_enum.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/integer_formats.rs b/crates/oapi-codegen/tests/generated/integer_formats.rs index 8ae6dbe..9c00277 100644 --- a/crates/oapi-codegen/tests/generated/integer_formats.rs +++ b/crates/oapi-codegen/tests/generated/integer_formats.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/map_alias.rs b/crates/oapi-codegen/tests/generated/map_alias.rs index 80b96df..8cf48d3 100644 --- a/crates/oapi-codegen/tests/generated/map_alias.rs +++ b/crates/oapi-codegen/tests/generated/map_alias.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/metadata_docs.rs b/crates/oapi-codegen/tests/generated/metadata_docs.rs index 06cd564..febc0f3 100644 --- a/crates/oapi-codegen/tests/generated/metadata_docs.rs +++ b/crates/oapi-codegen/tests/generated/metadata_docs.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/multi_spec_compose.rs b/crates/oapi-codegen/tests/generated/multi_spec_compose.rs index c696d40..1509223 100644 --- a/crates/oapi-codegen/tests/generated/multi_spec_compose.rs +++ b/crates/oapi-codegen/tests/generated/multi_spec_compose.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/nullable.rs b/crates/oapi-codegen/tests/generated/nullable.rs index 5422b94..f47b885 100644 --- a/crates/oapi-codegen/tests/generated/nullable.rs +++ b/crates/oapi-codegen/tests/generated/nullable.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/number_formats.rs b/crates/oapi-codegen/tests/generated/number_formats.rs index 96f4edf..8d7c026 100644 --- a/crates/oapi-codegen/tests/generated/number_formats.rs +++ b/crates/oapi-codegen/tests/generated/number_formats.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/object_additional_properties.rs b/crates/oapi-codegen/tests/generated/object_additional_properties.rs index 6940849..6dddc0c 100644 --- a/crates/oapi-codegen/tests/generated/object_additional_properties.rs +++ b/crates/oapi-codegen/tests/generated/object_additional_properties.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/object_deny_unknown_fields.rs b/crates/oapi-codegen/tests/generated/object_deny_unknown_fields.rs index ad96f8e..0153c24 100644 --- a/crates/oapi-codegen/tests/generated/object_deny_unknown_fields.rs +++ b/crates/oapi-codegen/tests/generated/object_deny_unknown_fields.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/object_nested_inline.rs b/crates/oapi-codegen/tests/generated/object_nested_inline.rs index 2613cb5..dac5576 100644 --- a/crates/oapi-codegen/tests/generated/object_nested_inline.rs +++ b/crates/oapi-codegen/tests/generated/object_nested_inline.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/object_optional_required.rs b/crates/oapi-codegen/tests/generated/object_optional_required.rs index 168d7b3..3a663f2 100644 --- a/crates/oapi-codegen/tests/generated/object_optional_required.rs +++ b/crates/oapi-codegen/tests/generated/object_optional_required.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/oneof_discriminator.rs b/crates/oapi-codegen/tests/generated/oneof_discriminator.rs index a37e3c2..1fda768 100644 --- a/crates/oapi-codegen/tests/generated/oneof_discriminator.rs +++ b/crates/oapi-codegen/tests/generated/oneof_discriminator.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/oneof_untagged.rs b/crates/oapi-codegen/tests/generated/oneof_untagged.rs index 8a197d2..5b10acc 100644 --- a/crates/oapi-codegen/tests/generated/oneof_untagged.rs +++ b/crates/oapi-codegen/tests/generated/oneof_untagged.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/oneof_variant_naming.rs b/crates/oapi-codegen/tests/generated/oneof_variant_naming.rs index b811c23..60bdf74 100644 --- a/crates/oapi-codegen/tests/generated/oneof_variant_naming.rs +++ b/crates/oapi-codegen/tests/generated/oneof_variant_naming.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations.rs new file mode 100644 index 0000000..7ec8f93 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations.rs @@ -0,0 +1,23 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "package_combined_keyword_operations/models.rs"] +mod models; +pub use models::*; +#[path = "package_combined_keyword_operations/operations.rs"] +mod operations; +pub use operations::*; +#[path = "package_combined_keyword_operations/server.rs"] +mod server; +pub use server::*; +#[path = "package_combined_keyword_operations/client.rs"] +mod client; +pub use client::*; diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client.rs new file mode 100644 index 0000000..ecde3f5 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client.rs @@ -0,0 +1,106 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "client/type.rs"] +mod r#type; +#[path = "client/mod_.rs"] +mod r#mod; +#[path = "client/self_.rs"] +mod self_; +#[path = "client/match.rs"] +mod r#match; + +/// 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), + /// The response cannot be decoded: a body that failed to + /// deserialize (for example malformed form-urlencoded content), or a + /// required response header that was missing or unparsable. + 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: {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'~'); + +/// A blocking HTTP client for the API. +/// +/// `base_url` is used as a prefix for every request path and must not +/// carry a trailing slash (for example `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` (for example preconfigured with timeouts). + pub fn with_client( + base_url: impl Into, + http: reqwest::blocking::Client, + ) -> Self { + return Self { + base_url: base_url.into(), + http, + }; + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/match.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/match.rs new file mode 100644 index 0000000..f714d3f --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/match.rs @@ -0,0 +1,37 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; +use super::PATH_PARAM_ENCODE_SET; + +impl Client { + pub fn r#match( + &self, + id: String, + body: Widget, + ) -> Result { + let url = format!( + "{}/match/{}", 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); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Widget = response.json()?; + return Ok(MatchResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/mod_.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/mod_.rs new file mode 100644 index 0000000..65bfb7c --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/mod_.rs @@ -0,0 +1,27 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + pub fn r#mod(&self) -> Result { + let url = format!("{}/mod", self.base_url); + let response = self.http.request(reqwest::Method::GET, url).send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Widget = response.json()?; + return Ok(ModResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/self_.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/self_.rs new file mode 100644 index 0000000..11e1671 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/self_.rs @@ -0,0 +1,27 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + pub fn self_(&self) -> Result { + let url = format!("{}/self", self.base_url); + let response = self.http.request(reqwest::Method::GET, url).send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Widget = response.json()?; + return Ok(SelfResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/type.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/type.rs new file mode 100644 index 0000000..9a464bc --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/client/type.rs @@ -0,0 +1,27 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + pub fn r#type(&self) -> Result { + let url = format!("{}/type", self.base_url); + let response = self.http.request(reqwest::Method::GET, url).send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Widget = response.json()?; + return Ok(TypeResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/models.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/models.rs new file mode 100644 index 0000000..6e94b59 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/models.rs @@ -0,0 +1,15 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Widget { + pub name: String, +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations.rs new file mode 100644 index 0000000..c64db8b --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations.rs @@ -0,0 +1,23 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "operations/type.rs"] +mod r#type; +pub use r#type::*; +#[path = "operations/mod_.rs"] +mod r#mod; +pub use r#mod::*; +#[path = "operations/self_.rs"] +mod self_; +pub use self_::*; +#[path = "operations/match.rs"] +mod r#match; +pub use r#match::*; diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/match.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/match.rs new file mode 100644 index 0000000..b56e670 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/match.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum MatchResponse { + /// A widget + Ok(Widget), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/mod_.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/mod_.rs new file mode 100644 index 0000000..729215f --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/mod_.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum ModResponse { + /// A widget + Ok(Widget), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/self_.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/self_.rs new file mode 100644 index 0000000..6af7603 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/self_.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum SelfResponse { + /// A widget + Ok(Widget), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/type.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/type.rs new file mode 100644 index 0000000..fd7fe77 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/operations/type.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone, PartialEq)] +pub enum TypeResponse { + /// A widget + Ok(Widget), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server.rs new file mode 100644 index 0000000..913bd75 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server.rs @@ -0,0 +1,48 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::models::*; +use super::operations::*; + +#[path = "server/type.rs"] +mod r#type; +use r#type::type_handler; +#[path = "server/mod_.rs"] +mod r#mod; +use r#mod::mod_handler; +#[path = "server/self_.rs"] +mod self_; +use self_::self_handler; +#[path = "server/match.rs"] +mod r#match; +use r#match::match_handler; + +/// Server behaviour: implement one method per operation. +pub trait Api: Clone + Send + Sync + 'static { + fn r#type(&self) -> impl std::future::Future + Send; + fn r#mod(&self) -> impl std::future::Future + Send; + fn self_(&self) -> impl std::future::Future + Send; + fn r#match( + &self, + id: String, + body: Widget, + ) -> impl std::future::Future + Send; +} + +/// Build an axum `Router` that dispatches each route to `api`. +pub fn router(api: T) -> axum::Router { + axum::Router::new() + .route("/type", axum::routing::get(type_handler::)) + .route("/mod", axum::routing::get(mod_handler::)) + .route("/self", axum::routing::get(self_handler::)) + .route("/match/{id}", axum::routing::post(match_handler::)) + .with_state(api) +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/match.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/match.rs new file mode 100644 index 0000000..f6272b9 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/match.rs @@ -0,0 +1,38 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for MatchResponse { + fn into_response(self) -> axum::response::Response { + match self { + MatchResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn match_handler( + axum::extract::State(api): axum::extract::State, + axum::extract::Path(id): axum::extract::Path, + axum::Json(body): axum::Json, +) -> MatchResponse { + api.r#match(id, body).await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/mod_.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/mod_.rs new file mode 100644 index 0000000..50c28e9 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/mod_.rs @@ -0,0 +1,36 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for ModResponse { + fn into_response(self) -> axum::response::Response { + match self { + ModResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn mod_handler( + axum::extract::State(api): axum::extract::State, +) -> ModResponse { + api.r#mod().await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/self_.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/self_.rs new file mode 100644 index 0000000..420873c --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/self_.rs @@ -0,0 +1,36 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for SelfResponse { + fn into_response(self) -> axum::response::Response { + match self { + SelfResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn self_handler( + axum::extract::State(api): axum::extract::State, +) -> SelfResponse { + api.self_().await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/type.rs b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/type.rs new file mode 100644 index 0000000..95f1c97 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_keyword_operations/server/type.rs @@ -0,0 +1,36 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for TypeResponse { + fn into_response(self) -> axum::response::Response { + match self { + TypeResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn type_handler( + axum::extract::State(api): axum::extract::State, +) -> TypeResponse { + api.r#type().await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names.rs new file mode 100644 index 0000000..967cca8 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names.rs @@ -0,0 +1,23 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "package_combined_prelude_value_names/models.rs"] +mod models; +pub use models::*; +#[path = "package_combined_prelude_value_names/operations.rs"] +mod operations; +pub use operations::*; +#[path = "package_combined_prelude_value_names/server.rs"] +mod server; +pub use server::*; +#[path = "package_combined_prelude_value_names/client.rs"] +mod client; +pub use client::*; diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/client.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/client.rs new file mode 100644 index 0000000..aa576d6 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/client.rs @@ -0,0 +1,96 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "client/upload.rs"] +mod upload; +#[path = "client/probe.rs"] +mod probe; + +/// 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), + /// The response cannot be decoded: a body that failed to + /// deserialize (for example malformed form-urlencoded content), or a + /// required response header that was missing or unparsable. + 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: {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); + } +} + +/// A blocking HTTP client for the API. +/// +/// `base_url` is used as a prefix for every request path and must not +/// carry a trailing slash (for example `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` (for example preconfigured with timeouts). + pub fn with_client( + base_url: impl Into, + http: reqwest::blocking::Client, + ) -> Self { + return Self { + base_url: base_url.into(), + http, + }; + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/client/probe.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/client/probe.rs new file mode 100644 index 0000000..e1d9ad7 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/client/probe.rs @@ -0,0 +1,38 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + pub fn probe(&self, query: ProbeQuery) -> Result { + let url = format!("{}/probe", self.base_url); + let mut request = self.http.request(reqwest::Method::GET, url); + if let Some(value) = &query.maybe { + request = request.query(&[("maybe", value.to_string())]); + } + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Some = response.json()?; + return Ok(ProbeResponse::Ok(body)); + } + if status.as_u16() == 204 { + return Ok(ProbeResponse::NoContent); + } + if status.as_u16() == 404 { + let body: None = response.json()?; + return Ok(ProbeResponse::NotFound(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/client/upload.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/client/upload.rs new file mode 100644 index 0000000..9fcd825 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/client/upload.rs @@ -0,0 +1,37 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + pub fn upload(&self, body: UploadMultipart) -> Result { + let url = format!("{}/upload", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + let mut form = reqwest::blocking::multipart::Form::new(); + 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() == 200 { + let body: Ok = response.json()?; + return Ok(UploadResponse::Ok(body)); + } + if status.as_u16() == 400 { + let body: Err = response.json()?; + return Ok(UploadResponse::BadRequest(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/models.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/models.rs new file mode 100644 index 0000000..de4e933 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/models.rs @@ -0,0 +1,35 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Ok { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Err { + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Some { + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct None { + pub note: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub holder: Option, +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations.rs new file mode 100644 index 0000000..8c6b956 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations.rs @@ -0,0 +1,17 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "operations/upload.rs"] +mod upload; +pub use upload::*; +#[path = "operations/probe.rs"] +mod probe; +pub use probe::*; diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations/probe.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations/probe.rs new file mode 100644 index 0000000..57c52d0 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations/probe.rs @@ -0,0 +1,28 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(serde::Deserialize, Debug, Clone, PartialEq)] +pub struct ProbeQuery { + #[serde(skip_serializing_if = "Option::is_none")] + pub maybe: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ProbeResponse { + /// some + Ok(Some), + /// empty + NoContent, + /// none + NotFound(None), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations/upload.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations/upload.rs new file mode 100644 index 0000000..b91dcb4 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/operations/upload.rs @@ -0,0 +1,25 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone)] +pub struct UploadMultipart { + pub caption: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum UploadResponse { + /// ok + Ok(Ok), + /// bad + BadRequest(Err), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/server.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/server.rs new file mode 100644 index 0000000..e1203ff --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/server.rs @@ -0,0 +1,40 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::models::*; +use super::operations::*; + +#[path = "server/upload.rs"] +mod upload; +use upload::upload_handler; +#[path = "server/probe.rs"] +mod probe; +use probe::probe_handler; + +/// Server behaviour: implement one method per operation. +pub trait Api: Clone + Send + Sync + 'static { + fn upload( + &self, + body: UploadMultipart, + ) -> impl std::future::Future + Send; + fn probe( + &self, + query: ProbeQuery, + ) -> impl std::future::Future + Send; +} + +/// Build an axum `Router` that dispatches each route to `api`. +pub fn router(api: T) -> axum::Router { + axum::Router::new() + .route("/upload", axum::routing::post(upload_handler::)) + .route("/probe", axum::routing::get(probe_handler::)) + .with_state(api) +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/server/probe.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/server/probe.rs new file mode 100644 index 0000000..f3116de --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/server/probe.rs @@ -0,0 +1,55 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for ProbeResponse { + fn into_response(self) -> axum::response::Response { + match self { + ProbeResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + ProbeResponse::NoContent => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 204, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + STATUS.into_response() + } + ProbeResponse::NotFound(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 404, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn probe_handler( + axum::extract::State(api): axum::extract::State, + axum_extra::extract::Query(query): axum_extra::extract::Query, +) -> ProbeResponse { + api.probe(query).await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/server/upload.rs b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/server/upload.rs new file mode 100644 index 0000000..3840310 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_prelude_value_names/server/upload.rs @@ -0,0 +1,91 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::extract::FromRequest for UploadMultipart +where + S: Send + Sync, +{ + type Rejection = (axum::http::StatusCode, String); + async fn from_request( + request: axum::extract::Request, + state: &S, + ) -> Result { + let mut multipart = >::from_request(request, state) + .await + .map_err(|error| { + return (axum::http::StatusCode::BAD_REQUEST, error.to_string()); + })?; + let mut caption: Option = None; + while let Some(field) = multipart + .next_field() + .await + .map_err(|error| { + return (axum::http::StatusCode::BAD_REQUEST, error.to_string()); + })? + { + let field_name = field.name().map(|name| return name.to_owned()); + match field_name.as_deref() { + Some("caption") => { + let value = field + .text() + .await + .map_err(|error| { + return ( + axum::http::StatusCode::BAD_REQUEST, + error.to_string(), + ); + })?; + caption = Some(value); + } + _ => {} + } + } + return Ok(Self { caption }); + } +} + +impl axum::response::IntoResponse for UploadResponse { + fn into_response(self) -> axum::response::Response { + match self { + UploadResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + UploadResponse::BadRequest(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 400, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn upload_handler( + axum::extract::State(api): axum::extract::State, + body: UploadMultipart, +) -> UploadResponse { + api.upload(body).await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision.rs b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision.rs new file mode 100644 index 0000000..e0c4b6c --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision.rs @@ -0,0 +1,23 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "package_combined_response_name_collision/models.rs"] +mod models; +pub use models::*; +#[path = "package_combined_response_name_collision/operations.rs"] +mod operations; +pub use operations::*; +#[path = "package_combined_response_name_collision/server.rs"] +mod server; +pub use server::*; +#[path = "package_combined_response_name_collision/client.rs"] +mod client; +pub use client::*; diff --git a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/client.rs b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/client.rs new file mode 100644 index 0000000..3c78752 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/client.rs @@ -0,0 +1,102 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "client/get_widget.rs"] +mod get_widget; +#[path = "client/replace_widget.rs"] +mod replace_widget; + +/// 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), + /// The response cannot be decoded: a body that failed to + /// deserialize (for example malformed form-urlencoded content), or a + /// required response header that was missing or unparsable. + 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: {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'~'); + +/// A blocking HTTP client for the API. +/// +/// `base_url` is used as a prefix for every request path and must not +/// carry a trailing slash (for example `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` (for example preconfigured with timeouts). + pub fn with_client( + base_url: impl Into, + http: reqwest::blocking::Client, + ) -> Self { + return Self { + base_url: base_url.into(), + http, + }; + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/client/get_widget.rs b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/client/get_widget.rs new file mode 100644 index 0000000..48a9f78 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/client/get_widget.rs @@ -0,0 +1,33 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; +use super::PATH_PARAM_ENCODE_SET; + +impl Client { + /// Fetch a single widget by identifier. + pub fn get_widget(&self, widget_id: String) -> Result { + let url = format!( + "{}/widgets/{}", self.base_url, + percent_encoding::utf8_percent_encode(widget_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: GetWidgetResponse = response.json()?; + return Ok(GetWidgetResp::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/client/replace_widget.rs b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/client/replace_widget.rs new file mode 100644 index 0000000..bc28c3f --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/client/replace_widget.rs @@ -0,0 +1,39 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; +use super::PATH_PARAM_ENCODE_SET; + +impl Client { + /// Replace a widget in full. + pub fn replace_widget( + &self, + widget_id: String, + body: GetWidgetResponse, + ) -> Result { + let url = format!( + "{}/widgets/{}", self.base_url, + percent_encoding::utf8_percent_encode(widget_id.as_str(), + PATH_PARAM_ENCODE_SET) + ); + let mut request = self.http.request(reqwest::Method::PUT, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: GetWidgetResponse = response.json()?; + return Ok(ReplaceWidgetResp::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/models.rs b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/models.rs new file mode 100644 index 0000000..ee411d0 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/models.rs @@ -0,0 +1,17 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct GetWidgetResponse { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/operations.rs b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/operations.rs new file mode 100644 index 0000000..8729914 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/operations.rs @@ -0,0 +1,17 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "operations/get_widget.rs"] +mod get_widget; +pub use get_widget::*; +#[path = "operations/replace_widget.rs"] +mod replace_widget; +pub use replace_widget::*; diff --git a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/operations/get_widget.rs b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/operations/get_widget.rs new file mode 100644 index 0000000..787ab6c --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/operations/get_widget.rs @@ -0,0 +1,19 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +/// Fetch a single widget by identifier. +#[derive(Debug, Clone, PartialEq)] +pub enum GetWidgetResp { + /// The requested widget. + Ok(GetWidgetResponse), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/operations/replace_widget.rs b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/operations/replace_widget.rs new file mode 100644 index 0000000..1ec7aa4 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/operations/replace_widget.rs @@ -0,0 +1,19 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +/// Replace a widget in full. +#[derive(Debug, Clone, PartialEq)] +pub enum ReplaceWidgetResp { + /// The updated widget. + Ok(GetWidgetResponse), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/server.rs b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/server.rs new file mode 100644 index 0000000..1bbb289 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/server.rs @@ -0,0 +1,45 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::models::*; +use super::operations::*; + +#[path = "server/get_widget.rs"] +mod get_widget; +use get_widget::get_widget_handler; +#[path = "server/replace_widget.rs"] +mod replace_widget; +use replace_widget::replace_widget_handler; + +/// Server behaviour: implement one method per operation. +pub trait Api: Clone + Send + Sync + 'static { + /// Fetch a single widget by identifier. + fn get_widget( + &self, + widget_id: String, + ) -> impl std::future::Future + Send; + /// Replace a widget in full. + fn replace_widget( + &self, + widget_id: String, + body: GetWidgetResponse, + ) -> impl std::future::Future + Send; +} + +/// Build an axum `Router` that dispatches each route to `api`. +pub fn router(api: T) -> axum::Router { + axum::Router::new() + .route( + "/widgets/{widgetId}", + axum::routing::get(get_widget_handler::).put(replace_widget_handler::), + ) + .with_state(api) +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/server/get_widget.rs b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/server/get_widget.rs new file mode 100644 index 0000000..400a2ad --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/server/get_widget.rs @@ -0,0 +1,37 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for GetWidgetResp { + fn into_response(self) -> axum::response::Response { + match self { + GetWidgetResp::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn get_widget_handler( + axum::extract::State(api): axum::extract::State, + axum::extract::Path(widget_id): axum::extract::Path, +) -> GetWidgetResp { + api.get_widget(widget_id).await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/server/replace_widget.rs b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/server/replace_widget.rs new file mode 100644 index 0000000..3c689f4 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_response_name_collision/server/replace_widget.rs @@ -0,0 +1,38 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for ReplaceWidgetResp { + fn into_response(self) -> axum::response::Response { + match self { + ReplaceWidgetResp::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn replace_widget_handler( + axum::extract::State(api): axum::extract::State, + axum::extract::Path(widget_id): axum::extract::Path, + axum::Json(body): axum::Json, +) -> ReplaceWidgetResp { + api.replace_widget(widget_id, body).await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_server_client.rs b/crates/oapi-codegen/tests/generated/package_combined_server_client.rs new file mode 100644 index 0000000..66da64e --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_server_client.rs @@ -0,0 +1,23 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "package_combined_server_client/models.rs"] +mod models; +pub use models::*; +#[path = "package_combined_server_client/operations.rs"] +mod operations; +pub use operations::*; +#[path = "package_combined_server_client/server.rs"] +mod server; +pub use server::*; +#[path = "package_combined_server_client/client.rs"] +mod client; +pub use client::*; diff --git a/crates/oapi-codegen/tests/generated/package_combined_server_client/client.rs b/crates/oapi-codegen/tests/generated/package_combined_server_client/client.rs new file mode 100644 index 0000000..c7e503b --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_server_client/client.rs @@ -0,0 +1,102 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "client/get_order.rs"] +mod get_order; +#[path = "client/replace_order.rs"] +mod replace_order; + +/// 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), + /// The response cannot be decoded: a body that failed to + /// deserialize (for example malformed form-urlencoded content), or a + /// required response header that was missing or unparsable. + 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: {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'~'); + +/// A blocking HTTP client for the API. +/// +/// `base_url` is used as a prefix for every request path and must not +/// carry a trailing slash (for example `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` (for example preconfigured with timeouts). + pub fn with_client( + base_url: impl Into, + http: reqwest::blocking::Client, + ) -> Self { + return Self { + base_url: base_url.into(), + http, + }; + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_server_client/client/get_order.rs b/crates/oapi-codegen/tests/generated/package_combined_server_client/client/get_order.rs new file mode 100644 index 0000000..8408dd8 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_server_client/client/get_order.rs @@ -0,0 +1,35 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; +use super::PATH_PARAM_ENCODE_SET; + +impl Client { + /// Fetch a single order by identifier. + pub fn get_order(&self, order_id: String) -> Result { + let url = format!( + "{}/orders/{}", self.base_url, percent_encoding::utf8_percent_encode(order_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: Order = response.json()?; + return Ok(GetOrderResponse::Ok(body)); + } + if status.as_u16() == 404 { + return Ok(GetOrderResponse::NotFound); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_server_client/client/replace_order.rs b/crates/oapi-codegen/tests/generated/package_combined_server_client/client/replace_order.rs new file mode 100644 index 0000000..6223421 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_server_client/client/replace_order.rs @@ -0,0 +1,38 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; +use super::PATH_PARAM_ENCODE_SET; + +impl Client { + /// Replace an order in full. + pub fn replace_order( + &self, + order_id: String, + body: Order, + ) -> Result { + let url = format!( + "{}/orders/{}", self.base_url, percent_encoding::utf8_percent_encode(order_id + .as_str(), PATH_PARAM_ENCODE_SET) + ); + let mut request = self.http.request(reqwest::Method::PUT, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Order = response.json()?; + return Ok(ReplaceOrderResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_server_client/models.rs b/crates/oapi-codegen/tests/generated/package_combined_server_client/models.rs new file mode 100644 index 0000000..5331dbb --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_server_client/models.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq)] +pub struct Order { + pub id: String, + pub total: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_server_client/operations.rs b/crates/oapi-codegen/tests/generated/package_combined_server_client/operations.rs new file mode 100644 index 0000000..e00f7f7 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_server_client/operations.rs @@ -0,0 +1,17 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "operations/get_order.rs"] +mod get_order; +pub use get_order::*; +#[path = "operations/replace_order.rs"] +mod replace_order; +pub use replace_order::*; diff --git a/crates/oapi-codegen/tests/generated/package_combined_server_client/operations/get_order.rs b/crates/oapi-codegen/tests/generated/package_combined_server_client/operations/get_order.rs new file mode 100644 index 0000000..67ecea4 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_server_client/operations/get_order.rs @@ -0,0 +1,21 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +/// Fetch a single order by identifier. +#[derive(Debug, Clone, PartialEq)] +pub enum GetOrderResponse { + /// The requested order. + Ok(Order), + /// No order has that identifier. + NotFound, +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_server_client/operations/replace_order.rs b/crates/oapi-codegen/tests/generated/package_combined_server_client/operations/replace_order.rs new file mode 100644 index 0000000..4eeb001 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_server_client/operations/replace_order.rs @@ -0,0 +1,19 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +/// Replace an order in full. +#[derive(Debug, Clone, PartialEq)] +pub enum ReplaceOrderResponse { + /// The updated order. + Ok(Order), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_server_client/server.rs b/crates/oapi-codegen/tests/generated/package_combined_server_client/server.rs new file mode 100644 index 0000000..1c17a30 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_server_client/server.rs @@ -0,0 +1,45 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::models::*; +use super::operations::*; + +#[path = "server/get_order.rs"] +mod get_order; +use get_order::get_order_handler; +#[path = "server/replace_order.rs"] +mod replace_order; +use replace_order::replace_order_handler; + +/// Server behaviour: implement one method per operation. +pub trait Api: Clone + Send + Sync + 'static { + /// Fetch a single order by identifier. + fn get_order( + &self, + order_id: String, + ) -> impl std::future::Future + Send; + /// Replace an order in full. + fn replace_order( + &self, + order_id: String, + body: Order, + ) -> impl std::future::Future + Send; +} + +/// Build an axum `Router` that dispatches each route to `api`. +pub fn router(api: T) -> axum::Router { + axum::Router::new() + .route( + "/orders/{orderId}", + axum::routing::get(get_order_handler::).put(replace_order_handler::), + ) + .with_state(api) +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_server_client/server/get_order.rs b/crates/oapi-codegen/tests/generated/package_combined_server_client/server/get_order.rs new file mode 100644 index 0000000..0031203 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_server_client/server/get_order.rs @@ -0,0 +1,46 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for GetOrderResponse { + fn into_response(self) -> axum::response::Response { + match self { + GetOrderResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + GetOrderResponse::NotFound => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 404, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + STATUS.into_response() + } + } + } +} + +pub(super) async fn get_order_handler( + axum::extract::State(api): axum::extract::State, + axum::extract::Path(order_id): axum::extract::Path, +) -> GetOrderResponse { + api.get_order(order_id).await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_server_client/server/replace_order.rs b/crates/oapi-codegen/tests/generated/package_combined_server_client/server/replace_order.rs new file mode 100644 index 0000000..df6dfc0 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_server_client/server/replace_order.rs @@ -0,0 +1,38 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for ReplaceOrderResponse { + fn into_response(self) -> axum::response::Response { + match self { + ReplaceOrderResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn replace_order_handler( + axum::extract::State(api): axum::extract::State, + axum::extract::Path(order_id): axum::extract::Path, + axum::Json(body): axum::Json, +) -> ReplaceOrderResponse { + api.replace_order(order_id, body).await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive.rs new file mode 100644 index 0000000..c20fe9f --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive.rs @@ -0,0 +1,23 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "package_combined_x_rust_derive/models.rs"] +mod models; +pub use models::*; +#[path = "package_combined_x_rust_derive/operations.rs"] +mod operations; +pub use operations::*; +#[path = "package_combined_x_rust_derive/server.rs"] +mod server; +pub use server::*; +#[path = "package_combined_x_rust_derive/client.rs"] +mod client; +pub use client::*; diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client.rs new file mode 100644 index 0000000..c5528cd --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client.rs @@ -0,0 +1,100 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "client/list_things.rs"] +mod list_things; +#[path = "client/create_thing.rs"] +mod create_thing; +#[path = "client/get_report.rs"] +mod get_report; +#[path = "client/upload.rs"] +mod upload; + +/// 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), + /// The response cannot be decoded: a body that failed to + /// deserialize (for example malformed form-urlencoded content), or a + /// required response header that was missing or unparsable. + 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: {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); + } +} + +/// A blocking HTTP client for the API. +/// +/// `base_url` is used as a prefix for every request path and must not +/// carry a trailing slash (for example `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` (for example preconfigured with timeouts). + pub fn with_client( + base_url: impl Into, + http: reqwest::blocking::Client, + ) -> Self { + return Self { + base_url: base_url.into(), + http, + }; + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/create_thing.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/create_thing.rs new file mode 100644 index 0000000..eea65bf --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/create_thing.rs @@ -0,0 +1,30 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + /// A request body of the same model. + pub fn create_thing(&self, body: Thing) -> Result { + let url = format!("{}/things", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + request = request.json(&body); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 201 { + let body: Thing = response.json()?; + return Ok(CreateThingResponse::Created(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/get_report.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/get_report.rs new file mode 100644 index 0000000..dfd730d --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/get_report.rs @@ -0,0 +1,49 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + /// A negotiated response holding the restricted model. + pub fn get_report(&self) -> Result { + let url = format!("{}/report", self.base_url); + let response = self.http.request(reqwest::Method::GET, url).send()?; + let status = response.status(); + if status.as_u16() == 200 { + 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") + { + GetReportResponseOkBody::Json(response.json()?) + } else if content_type == "text/plain" { + GetReportResponseOkBody::Text(response.text()?) + } else { + return Err(ClientError::UnexpectedContentType(content_type)); + }; + return Ok(GetReportResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/list_things.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/list_things.rs new file mode 100644 index 0000000..7c5b6a9 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/list_things.rs @@ -0,0 +1,28 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + /// A response body whose model drops Clone and PartialEq. + pub fn list_things(&self) -> Result { + let url = format!("{}/things", self.base_url); + let response = self.http.request(reqwest::Method::GET, url).send()?; + let status = response.status(); + if status.as_u16() == 200 { + let body: Thing = response.json()?; + return Ok(ListThingsResponse::Ok(body)); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/upload.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/upload.rs new file mode 100644 index 0000000..2a0fa3d --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/client/upload.rs @@ -0,0 +1,32 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + /// A multipart request beside the restricted models. + pub fn upload(&self, body: UploadMultipart) -> Result { + let url = format!("{}/upload", self.base_url); + let mut request = self.http.request(reqwest::Method::POST, url); + let mut form = reqwest::blocking::multipart::Form::new(); + form = form.text("opaque", body.opaque.to_string()); + form = form.text("note", body.note.to_string()); + request = request.multipart(form); + let response = request.send()?; + let status = response.status(); + if status.as_u16() == 204 { + return Ok(UploadResponse::NoContent); + } + return Err(ClientError::UnexpectedStatus(status)); + } +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/models.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/models.rs new file mode 100644 index 0000000..b21dcff --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/models.rs @@ -0,0 +1,18 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +pub type Opaque = crate::restricted::Opaque; + +#[derive(serde::Serialize, serde::Deserialize, Debug)] +pub struct Thing { + pub id: String, + pub opaque: Opaque, +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations.rs new file mode 100644 index 0000000..72dd482 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations.rs @@ -0,0 +1,23 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "operations/list_things.rs"] +mod list_things; +pub use list_things::*; +#[path = "operations/create_thing.rs"] +mod create_thing; +pub use create_thing::*; +#[path = "operations/get_report.rs"] +mod get_report; +pub use get_report::*; +#[path = "operations/upload.rs"] +mod upload; +pub use upload::*; diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/create_thing.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/create_thing.rs new file mode 100644 index 0000000..619ae8a --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/create_thing.rs @@ -0,0 +1,19 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +/// A request body of the same model. +#[derive(Debug)] +pub enum CreateThingResponse { + /// The created thing. + Created(Thing), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/get_report.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/get_report.rs new file mode 100644 index 0000000..9fb6ff0 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/get_report.rs @@ -0,0 +1,25 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug)] +pub enum GetReportResponseOkBody { + Json(Thing), + Text(String), +} + +/// A negotiated response holding the restricted model. +#[derive(Debug)] +pub enum GetReportResponse { + /// The report, as JSON or plain text. + Ok(GetReportResponseOkBody), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/list_things.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/list_things.rs new file mode 100644 index 0000000..d0a9547 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/list_things.rs @@ -0,0 +1,19 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +/// A response body whose model drops Clone and PartialEq. +#[derive(Debug)] +pub enum ListThingsResponse { + /// The things. + Ok(Thing), +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/upload.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/upload.rs new file mode 100644 index 0000000..e6bc707 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/operations/upload.rs @@ -0,0 +1,25 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; + +#[derive(Debug, Clone)] +pub struct UploadMultipart { + pub opaque: String, + pub note: String, +} + +/// A multipart request beside the restricted models. +#[derive(Debug, Clone, PartialEq)] +pub enum UploadResponse { + /// The upload was stored. + NoContent, +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server.rs new file mode 100644 index 0000000..58ec46b --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server.rs @@ -0,0 +1,58 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::models::*; +use super::operations::*; + +#[path = "server/list_things.rs"] +mod list_things; +use list_things::list_things_handler; +#[path = "server/create_thing.rs"] +mod create_thing; +use create_thing::create_thing_handler; +#[path = "server/get_report.rs"] +mod get_report; +use get_report::get_report_handler; +#[path = "server/upload.rs"] +mod upload; +use upload::upload_handler; + +/// Server behaviour: implement one method per operation. +pub trait Api: Clone + Send + Sync + 'static { + /// A response body whose model drops Clone and PartialEq. + fn list_things( + &self, + ) -> impl std::future::Future + Send; + /// A request body of the same model. + fn create_thing( + &self, + body: Thing, + ) -> impl std::future::Future + Send; + /// A negotiated response holding the restricted model. + fn get_report(&self) -> impl std::future::Future + Send; + /// A multipart request beside the restricted models. + fn upload( + &self, + body: UploadMultipart, + ) -> impl std::future::Future + Send; +} + +/// Build an axum `Router` that dispatches each route to `api`. +pub fn router(api: T) -> axum::Router { + axum::Router::new() + .route( + "/things", + axum::routing::get(list_things_handler::).post(create_thing_handler::), + ) + .route("/report", axum::routing::get(get_report_handler::)) + .route("/upload", axum::routing::post(upload_handler::)) + .with_state(api) +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/create_thing.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/create_thing.rs new file mode 100644 index 0000000..d60360b --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/create_thing.rs @@ -0,0 +1,37 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for CreateThingResponse { + fn into_response(self) -> axum::response::Response { + match self { + CreateThingResponse::Created(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 201, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn create_thing_handler( + axum::extract::State(api): axum::extract::State, + axum::Json(body): axum::Json, +) -> CreateThingResponse { + api.create_thing(body).await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/get_report.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/get_report.rs new file mode 100644 index 0000000..6aa0b95 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/get_report.rs @@ -0,0 +1,41 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for GetReportResponse { + fn into_response(self) -> axum::response::Response { + match self { + GetReportResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + match body { + GetReportResponseOkBody::Json(body) => { + (STATUS, axum::Json(body)).into_response() + } + GetReportResponseOkBody::Text(body) => (STATUS, body).into_response(), + } + } + } + } +} + +pub(super) async fn get_report_handler( + axum::extract::State(api): axum::extract::State, +) -> GetReportResponse { + api.get_report().await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/list_things.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/list_things.rs new file mode 100644 index 0000000..40fd6da --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/list_things.rs @@ -0,0 +1,36 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for ListThingsResponse { + fn into_response(self) -> axum::response::Response { + match self { + ListThingsResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn list_things_handler( + axum::extract::State(api): axum::extract::State, +) -> ListThingsResponse { + api.list_things().await +} diff --git a/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/upload.rs b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/upload.rs new file mode 100644 index 0000000..4433442 --- /dev/null +++ b/crates/oapi-codegen/tests/generated/package_combined_x_rust_derive/server/upload.rs @@ -0,0 +1,106 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::models::*; +use super::super::operations::*; +use super::Api; + +impl axum::extract::FromRequest for UploadMultipart +where + S: Send + Sync, +{ + type Rejection = (axum::http::StatusCode, String); + async fn from_request( + request: axum::extract::Request, + state: &S, + ) -> Result { + let mut multipart = >::from_request(request, state) + .await + .map_err(|error| { + return (axum::http::StatusCode::BAD_REQUEST, error.to_string()); + })?; + let mut opaque: Option = None; + let mut note: Option = None; + while let Some(field) = multipart + .next_field() + .await + .map_err(|error| { + return (axum::http::StatusCode::BAD_REQUEST, error.to_string()); + })? + { + let field_name = field.name().map(|name| return name.to_owned()); + match field_name.as_deref() { + Some("opaque") => { + let value = field + .text() + .await + .map_err(|error| { + return ( + axum::http::StatusCode::BAD_REQUEST, + error.to_string(), + ); + })?; + opaque = Some(value); + } + Some("note") => { + let value = field + .text() + .await + .map_err(|error| { + return ( + axum::http::StatusCode::BAD_REQUEST, + error.to_string(), + ); + })?; + note = Some(value); + } + _ => {} + } + } + return Ok(Self { + opaque: opaque + .ok_or(( + axum::http::StatusCode::BAD_REQUEST, + "missing required multipart field `opaque`".to_owned(), + ))?, + note: note + .ok_or(( + axum::http::StatusCode::BAD_REQUEST, + "missing required multipart field `note`".to_owned(), + ))?, + }); + } +} + +impl axum::response::IntoResponse for UploadResponse { + fn into_response(self) -> axum::response::Response { + match self { + UploadResponse::NoContent => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 204, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + STATUS.into_response() + } + } + } +} + +pub(super) async fn upload_handler( + axum::extract::State(api): axum::extract::State, + body: UploadMultipart, +) -> UploadResponse { + api.upload(body).await +} diff --git a/crates/oapi-codegen/tests/generated/prelude_result_name.rs b/crates/oapi-codegen/tests/generated/prelude_result_name.rs index b953dc0..8c9cb6e 100644 --- a/crates/oapi-codegen/tests/generated/prelude_result_name.rs +++ b/crates/oapi-codegen/tests/generated/prelude_result_name.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/prelude_value_names.rs b/crates/oapi-codegen/tests/generated/prelude_value_names.rs index 01ec3bb..e2ef620 100644 --- a/crates/oapi-codegen/tests/generated/prelude_value_names.rs +++ b/crates/oapi-codegen/tests/generated/prelude_value_names.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/primitive_scalars.rs b/crates/oapi-codegen/tests/generated/primitive_scalars.rs index f4f4a2e..d57e5a2 100644 --- a/crates/oapi-codegen/tests/generated/primitive_scalars.rs +++ b/crates/oapi-codegen/tests/generated/primitive_scalars.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/recursive_schema.rs b/crates/oapi-codegen/tests/generated/recursive_schema.rs index 10250fc..214a5d8 100644 --- a/crates/oapi-codegen/tests/generated/recursive_schema.rs +++ b/crates/oapi-codegen/tests/generated/recursive_schema.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/ref_local.rs b/crates/oapi-codegen/tests/generated/ref_local.rs index 4f03cd0..6818376 100644 --- a/crates/oapi-codegen/tests/generated/ref_local.rs +++ b/crates/oapi-codegen/tests/generated/ref_local.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/schema_defaults.rs b/crates/oapi-codegen/tests/generated/schema_defaults.rs index e0f767d..cb687a1 100644 --- a/crates/oapi-codegen/tests/generated/schema_defaults.rs +++ b/crates/oapi-codegen/tests/generated/schema_defaults.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_auth.rs b/crates/oapi-codegen/tests/generated/server_auth.rs index 4aba786..7651650 100644 --- a/crates/oapi-codegen/tests/generated/server_auth.rs +++ b/crates/oapi-codegen/tests/generated/server_auth.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_component_body_ref.rs b/crates/oapi-codegen/tests/generated/server_component_body_ref.rs index e125237..2c394e3 100644 --- a/crates/oapi-codegen/tests/generated/server_component_body_ref.rs +++ b/crates/oapi-codegen/tests/generated/server_component_body_ref.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_component_param_ref.rs b/crates/oapi-codegen/tests/generated/server_component_param_ref.rs index 3316037..aa1041a 100644 --- a/crates/oapi-codegen/tests/generated/server_component_param_ref.rs +++ b/crates/oapi-codegen/tests/generated/server_component_param_ref.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_component_param_ref_pet.rs b/crates/oapi-codegen/tests/generated/server_component_param_ref_pet.rs index 9818c57..8e39918 100644 --- a/crates/oapi-codegen/tests/generated/server_component_param_ref_pet.rs +++ b/crates/oapi-codegen/tests/generated/server_component_param_ref_pet.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_cookie_params.rs b/crates/oapi-codegen/tests/generated/server_cookie_params.rs index 2f6c222..c462d93 100644 --- a/crates/oapi-codegen/tests/generated/server_cookie_params.rs +++ b/crates/oapi-codegen/tests/generated/server_cookie_params.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_default_range_responses.rs b/crates/oapi-codegen/tests/generated/server_default_range_responses.rs index efd2bf5..95bcf58 100644 --- a/crates/oapi-codegen/tests/generated/server_default_range_responses.rs +++ b/crates/oapi-codegen/tests/generated/server_default_range_responses.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_filtering.rs b/crates/oapi-codegen/tests/generated/server_filtering.rs index 6beb723..df8a73e 100644 --- a/crates/oapi-codegen/tests/generated/server_filtering.rs +++ b/crates/oapi-codegen/tests/generated/server_filtering.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_form_body.rs b/crates/oapi-codegen/tests/generated/server_form_body.rs index 7c8751a..09628b2 100644 --- a/crates/oapi-codegen/tests/generated/server_form_body.rs +++ b/crates/oapi-codegen/tests/generated/server_form_body.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_header_params.rs b/crates/oapi-codegen/tests/generated/server_header_params.rs index d455aaf..7edbb23 100644 --- a/crates/oapi-codegen/tests/generated/server_header_params.rs +++ b/crates/oapi-codegen/tests/generated/server_header_params.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_json_charset.rs b/crates/oapi-codegen/tests/generated/server_json_charset.rs index e06e399..f12a086 100644 --- a/crates/oapi-codegen/tests/generated/server_json_charset.rs +++ b/crates/oapi-codegen/tests/generated/server_json_charset.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_multi_content_request.rs b/crates/oapi-codegen/tests/generated/server_multi_content_request.rs index 955eb72..5e8de9e 100644 --- a/crates/oapi-codegen/tests/generated/server_multi_content_request.rs +++ b/crates/oapi-codegen/tests/generated/server_multi_content_request.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_multi_content_response.rs b/crates/oapi-codegen/tests/generated/server_multi_content_response.rs index 6d08fd1..60da835 100644 --- a/crates/oapi-codegen/tests/generated/server_multi_content_response.rs +++ b/crates/oapi-codegen/tests/generated/server_multi_content_response.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_multipart_body.rs b/crates/oapi-codegen/tests/generated/server_multipart_body.rs index 55eda20..424c113 100644 --- a/crates/oapi-codegen/tests/generated/server_multipart_body.rs +++ b/crates/oapi-codegen/tests/generated/server_multipart_body.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_petstore.rs b/crates/oapi-codegen/tests/generated/server_petstore.rs index abe944b..3eb574b 100644 --- a/crates/oapi-codegen/tests/generated/server_petstore.rs +++ b/crates/oapi-codegen/tests/generated/server_petstore.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_prune.rs b/crates/oapi-codegen/tests/generated/server_prune.rs index e65cc41..f4e0940 100644 --- a/crates/oapi-codegen/tests/generated/server_prune.rs +++ b/crates/oapi-codegen/tests/generated/server_prune.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_query_params.rs b/crates/oapi-codegen/tests/generated/server_query_params.rs index 011a3bf..07c90c3 100644 --- a/crates/oapi-codegen/tests/generated/server_query_params.rs +++ b/crates/oapi-codegen/tests/generated/server_query_params.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_refs.rs b/crates/oapi-codegen/tests/generated/server_refs.rs index fb0dd68..db278aa 100644 --- a/crates/oapi-codegen/tests/generated/server_refs.rs +++ b/crates/oapi-codegen/tests/generated/server_refs.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_response_headers.rs b/crates/oapi-codegen/tests/generated/server_response_headers.rs index d4059f9..04b908c 100644 --- a/crates/oapi-codegen/tests/generated/server_response_headers.rs +++ b/crates/oapi-codegen/tests/generated/server_response_headers.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_text_body.rs b/crates/oapi-codegen/tests/generated/server_text_body.rs index 0b23bfb..9ed263e 100644 --- a/crates/oapi-codegen/tests/generated/server_text_body.rs +++ b/crates/oapi-codegen/tests/generated/server_text_body.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_urls.rs b/crates/oapi-codegen/tests/generated/server_urls.rs index b8df406..1bf0b9f 100644 --- a/crates/oapi-codegen/tests/generated/server_urls.rs +++ b/crates/oapi-codegen/tests/generated/server_urls.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/server_xfile_refs.rs b/crates/oapi-codegen/tests/generated/server_xfile_refs.rs index edaac9f..ea116b8 100644 --- a/crates/oapi-codegen/tests/generated/server_xfile_refs.rs +++ b/crates/oapi-codegen/tests/generated/server_xfile_refs.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/string_enum.rs b/crates/oapi-codegen/tests/generated/string_enum.rs index 71efaf8..6aac11c 100644 --- a/crates/oapi-codegen/tests/generated/string_enum.rs +++ b/crates/oapi-codegen/tests/generated/string_enum.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/string_formats.rs b/crates/oapi-codegen/tests/generated/string_formats.rs index fc7dbc7..8cff635 100644 --- a/crates/oapi-codegen/tests/generated/string_formats.rs +++ b/crates/oapi-codegen/tests/generated/string_formats.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/type_name_collisions.rs b/crates/oapi-codegen/tests/generated/type_name_collisions.rs index fa58dfc..ca5af4c 100644 --- a/crates/oapi-codegen/tests/generated/type_name_collisions.rs +++ b/crates/oapi-codegen/tests/generated/type_name_collisions.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/generated/value_constraints.rs b/crates/oapi-codegen/tests/generated/value_constraints.rs index 1f42776..e5ec967 100644 --- a/crates/oapi-codegen/tests/generated/value_constraints.rs +++ b/crates/oapi-codegen/tests/generated/value_constraints.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/crates/oapi-codegen/tests/package.rs b/crates/oapi-codegen/tests/package.rs new file mode 100644 index 0000000..68ed8f3 --- /dev/null +++ b/crates/oapi-codegen/tests/package.rs @@ -0,0 +1,698 @@ +//! The shape of the split output, and the file operations over it. +//! +//! The golden files under `tests/generated/package_*` pin what the split output +//! contains and prove it compiles. These cases cover what a golden cannot: that +//! the split loses nothing the flat layout emits, and that writing and checking +//! a package reconcile the companion directory rather than only its files. + +use std::collections::BTreeSet; +use std::path::Path; +use std::path::PathBuf; + +use oapi_codegen::PackageDrift; + +/// A spec with one operation, one component schema, and a security scheme, so +/// its output fills every module the emitter can produce. +const SPEC: &str = "\ +openapi: 3.0.3 +info: + title: Demo + version: 1.0.0 +servers: + - url: https://api.example.com +paths: + /widgets/{id}: + get: + operationId: getWidget + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: The widget + content: + application/json: + schema: + $ref: '#/components/schemas/Widget' +components: + schemas: + Widget: + type: object + required: [name] + properties: + name: + type: string +"; + +/// A models-only spec, which produces no operations and so no companion files. +const MODELS_ONLY_SPEC: &str = "\ +openapi: 3.0.3 +info: + title: Demo + version: 1.0.0 +paths: {} +components: + schemas: + Widget: + type: object + properties: + name: + type: string +"; + +/// A directory under the temp directory that goes away with the test. +struct TestDir { + path: PathBuf, +} + +impl TestDir { + fn new(test_name: &str) -> Self { + let elapsed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_else(|err| panic!("system clock should be after the Unix epoch: {err}")); + let unique = format!( + "oapi-codegen-package-{test_name}-{}-{}", + std::process::id(), + elapsed.as_nanos(), + ); + let path = std::env::temp_dir().join(unique); + std::fs::create_dir_all(&path).unwrap_or_else(|err| panic!("creating `{}` failed: {err}", path.display())); + return Self { path }; + } + + /// Write `contents` to `file`, creating the directories it needs. + fn write(&self, file: &str, contents: &str) -> PathBuf { + let path = self.path.join(file); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .unwrap_or_else(|err| panic!("creating `{}` failed: {err}", parent.display())); + } + std::fs::write(&path, contents).unwrap_or_else(|err| panic!("writing `{}` failed: {err}", path.display())); + return path; + } + + /// Write `SPEC` and give back the path to it. + fn spec(&self) -> PathBuf { + return self.write("spec.yaml", SPEC); + } + + fn join(&self, file: &str) -> PathBuf { + return self.path.join(file); + } + + /// The output path every case here generates to. + fn output(&self) -> PathBuf { + return self.join("restapi.rs"); + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// A configuration that turns on every generator, so the output spans models, +/// operations, server, and client. +fn full_config() -> oapi_codegen::Config { + return oapi_codegen::Config { + generate: oapi_codegen::config::Generate { + models: true, + server_urls: true, + std_http_server: true, + client: true, + ..Default::default() + }, + ..Default::default() + }; +} + +/// A configuration with no operations to split, so the output is one file. +fn models_only_config() -> oapi_codegen::Config { + return oapi_codegen::Config { + generate: oapi_codegen::config::Generate { + models: true, + ..Default::default() + }, + ..Default::default() + }; +} + +/// Generate `spec` as a package, failing the test with the generator's message. +fn package_for(spec: &Path, config: &oapi_codegen::Config, output: &Path) -> oapi_codegen::GeneratedPackage { + return oapi_codegen::generate_package(spec, config, output) + .unwrap_or_else(|err| panic!("generating `{}` failed: {err}", spec.display())); +} + +/// The relative path of every companion file, sorted. +fn child_paths(package: &oapi_codegen::GeneratedPackage) -> Vec { + let mut paths: Vec = package + .children() + .iter() + .map(|child| return child.path().to_string_lossy().replace('\\', "/")) + .collect(); + paths.sort(); + return paths; +} + +/// The name of every item `source` declares. +/// +/// This reads the code as text on purpose. The point is to compare two +/// renderings of the same generator run, and a scan that treats both the same +/// way answers that without a parser. +fn declared_names(source: &str) -> BTreeSet { + const VISIBILITIES: &[&str] = &["pub(crate) ", "pub(super) ", "pub "]; + const KEYWORDS: &[&str] = &["struct ", "enum ", "trait ", "fn ", "type ", "const ", "static "]; + + let mut names = BTreeSet::new(); + for line in source.lines() { + let mut rest = line.trim(); + for visibility in VISIBILITIES { + if let Some(tail) = rest.strip_prefix(visibility) { + rest = tail; + break; + } + } + rest = rest.strip_prefix("async ").unwrap_or(rest); + for keyword in KEYWORDS { + let Some(tail) = rest.strip_prefix(keyword) else { + continue; + }; + let name: String = tail + .chars() + .take_while(|character| return character.is_alphanumeric() || *character == '_') + .collect(); + if !name.is_empty() { + names.insert(name); + } + break; + } + } + return names; +} + +#[test] +fn package_holds_one_module_per_concern_and_one_file_per_operation() { + let dir = TestDir::new("layout"); + let package = package_for(&dir.spec(), &full_config(), &dir.output()); + + assert_eq!( + child_paths(&package), + vec![ + "restapi/client.rs", + "restapi/client/get_widget.rs", + "restapi/models.rs", + "restapi/operations.rs", + "restapi/operations/get_widget.rs", + "restapi/server.rs", + "restapi/server/get_widget.rs", + "restapi/server_urls.rs", + ], + ); + assert_eq!(package.file_count(), 9, "the root file counts too"); +} + +#[test] +fn the_root_file_mounts_and_reexports_every_module() { + let dir = TestDir::new("root"); + let package = package_for(&dir.spec(), &full_config(), &dir.output()); + let root = package.root_source(); + + for module in ["models", "server_urls", "operations", "server", "client"] { + assert!( + root.contains(&format!("#[path = \"restapi/{module}.rs\"]\nmod {module};")), + "the root file should mount `{module}`, but holds:\n{root}", + ); + assert!( + root.contains(&format!("pub use {module}::*;")), + "the root file should re-export `{module}`, but holds:\n{root}", + ); + } +} + +#[test] +fn the_root_file_names_only_modules_that_have_content() { + let dir = TestDir::new("root_sparse"); + let config = oapi_codegen::Config { + generate: oapi_codegen::config::Generate { + std_http_server: true, + ..Default::default() + }, + ..Default::default() + }; + let package = package_for(&dir.spec(), &config, &dir.output()); + + assert!( + !package.root_source().contains("mod client;"), + "a run without the client generator should not mount a client module", + ); + assert!( + !child_paths(&package).iter().any(|path| return path.contains("client")), + "a run without the client generator should produce no client files", + ); +} + +#[test] +fn splitting_the_output_declares_the_same_items_as_the_flat_layout() { + let dir = TestDir::new("items"); + let spec = dir.spec(); + let config = full_config(); + + let flat = oapi_codegen::generate(&spec, &config).unwrap_or_else(|err| panic!("generating flat failed: {err}")); + let package = package_for(&spec, &config, &dir.output()); + + assert_eq!( + declared_names(&package.combined_source()), + declared_names(&flat), + "the split output should declare exactly what the flat output declares", + ); +} + +#[test] +fn a_run_without_operations_produces_a_single_file() { + let dir = TestDir::new("models_only"); + let spec = dir.write("spec.yaml", MODELS_ONLY_SPEC); + let config = models_only_config(); + + let package = package_for(&spec, &config, &dir.output()); + let flat = oapi_codegen::generate(&spec, &config).unwrap_or_else(|err| panic!("generating flat failed: {err}")); + + assert!( + package.children().is_empty(), + "a models-only run needs no companion files" + ); + assert_eq!( + package.root_source(), + flat, + "a models-only run should write what it has always written", + ); +} + +#[test] +fn a_run_without_operations_removes_the_companion_directory_an_earlier_run_left() { + let dir = TestDir::new("models_only_cleanup"); + let output = dir.output(); + let split = package_for(&dir.spec(), &full_config(), &output); + oapi_codegen::write_package(&output, &split).unwrap_or_else(|err| panic!("writing the split failed: {err}")); + + let spec = dir.write("models.yaml", MODELS_ONLY_SPEC); + let package = package_for(&spec, &models_only_config(), &output); + oapi_codegen::write_package(&output, &package).unwrap_or_else(|err| panic!("writing the models failed: {err}")); + + assert!( + !dir.join("restapi").exists(), + "the companion directory an earlier run left should go", + ); + assert!(output.is_file(), "the root file should hold the models-only output"); +} + +#[test] +fn a_leftover_generated_file_is_drift_for_a_run_without_operations() { + let dir = TestDir::new("models_only_stale_check"); + let output = dir.output(); + let spec = dir.write("models.yaml", MODELS_ONLY_SPEC); + let package = package_for(&spec, &models_only_config(), &output); + oapi_codegen::write_package(&output, &package).unwrap_or_else(|err| panic!("writing failed: {err}")); + + let stale = dir.write( + "restapi/operations.rs", + "// Code generated by oapi-codegen-rust. DO NOT EDIT.\n", + ); + + assert_eq!( + oapi_codegen::check_package(&output, &package).unwrap_or_else(|err| panic!("checking failed: {err}")), + PackageDrift::Stale(stale), + "a check should report a generated file beside an output the run no longer splits", + ); +} + +#[test] +fn a_run_without_operations_accepts_an_output_path_with_no_extension() { + let dir = TestDir::new("models_only_no_extension"); + let spec = dir.write("models.yaml", MODELS_ONLY_SPEC); + // Such a path can carry no companion directory, and a run that produces no + // companion files does not need one. + let output = dir.join("restapi"); + let package = package_for(&spec, &models_only_config(), &output); + + oapi_codegen::write_package(&output, &package).unwrap_or_else(|err| panic!("writing failed: {err}")); + + assert!(output.is_file(), "the root file should exist"); + assert_eq!( + oapi_codegen::check_package(&output, &package).unwrap_or_else(|err| panic!("checking failed: {err}")), + PackageDrift::None, + "a check right after a write should find no drift", + ); +} + +#[test] +fn writing_a_package_creates_every_file() { + let dir = TestDir::new("write"); + let output = dir.output(); + let package = package_for(&dir.spec(), &full_config(), &output); + + oapi_codegen::write_package(&output, &package).unwrap_or_else(|err| panic!("writing failed: {err}")); + + assert!(output.is_file(), "the root file should exist"); + for child in package.children() { + let path = dir.path.join(child.path()); + assert!(path.is_file(), "`{}` should exist", path.display()); + } + assert_eq!( + oapi_codegen::check_package(&output, &package).unwrap_or_else(|err| panic!("checking failed: {err}")), + PackageDrift::None, + "a check right after a write should find no drift", + ); +} + +#[test] +fn writing_a_package_removes_a_generated_file_the_run_no_longer_produces() { + let dir = TestDir::new("cleanup"); + let output = dir.output(); + let package = package_for(&dir.spec(), &full_config(), &output); + oapi_codegen::write_package(&output, &package).unwrap_or_else(|err| panic!("writing failed: {err}")); + + let stale = dir.write( + "restapi/operations/removed_widget.rs", + "// Code generated by oapi-codegen-rust. DO NOT EDIT.\n", + ); + let nested = dir.write( + "restapi/gone/deeper.rs", + "// Code generated by oapi-codegen-rust. DO NOT EDIT.\n", + ); + + oapi_codegen::write_package(&output, &package).unwrap_or_else(|err| panic!("rewriting failed: {err}")); + + assert!(!stale.exists(), "a generated file the run does not produce should go"); + assert!(!nested.exists(), "the walk should reach nested directories"); + assert!( + !dir.join("restapi/gone").exists(), + "a directory the cleanup empties should go too", + ); + assert!(output.is_file(), "the root file should survive"); +} + +#[test] +fn a_leftover_generated_file_is_drift() { + let dir = TestDir::new("stale_check"); + let output = dir.output(); + let package = package_for(&dir.spec(), &full_config(), &output); + oapi_codegen::write_package(&output, &package).unwrap_or_else(|err| panic!("writing failed: {err}")); + + let stale = dir.write( + "restapi/operations/removed_widget.rs", + "// Code generated by oapi-codegen-rust. DO NOT EDIT.\n", + ); + + assert_eq!( + oapi_codegen::check_package(&output, &package).unwrap_or_else(|err| panic!("checking failed: {err}")), + PackageDrift::Stale(stale), + ); +} + +#[test] +fn a_missing_or_changed_companion_file_is_drift() { + let dir = TestDir::new("child_drift"); + let output = dir.output(); + let package = package_for(&dir.spec(), &full_config(), &output); + + assert_eq!( + oapi_codegen::check_package(&output, &package).unwrap_or_else(|err| panic!("checking failed: {err}")), + PackageDrift::Absent(output.clone()), + "nothing on disk should report the root as absent", + ); + + oapi_codegen::write_package(&output, &package).unwrap_or_else(|err| panic!("writing failed: {err}")); + + let child = dir.join("restapi/models.rs"); + std::fs::remove_file(&child).unwrap_or_else(|err| panic!("removing `{}` failed: {err}", child.display())); + assert_eq!( + oapi_codegen::check_package(&output, &package).unwrap_or_else(|err| panic!("checking failed: {err}")), + PackageDrift::Absent(child.clone()), + "a companion file the run produces should be reported when it is missing", + ); + + dir.write( + "restapi/models.rs", + "// Code generated by oapi-codegen-rust. DO NOT EDIT.\n// edited\n", + ); + assert_eq!( + oapi_codegen::check_package(&output, &package).unwrap_or_else(|err| panic!("checking failed: {err}")), + PackageDrift::Differs(child), + "an edited companion file should be reported", + ); +} + +#[test] +fn a_file_the_generator_did_not_write_is_refused() { + let dir = TestDir::new("unowned"); + let output = dir.output(); + let package = package_for(&dir.spec(), &full_config(), &output); + oapi_codegen::write_package(&output, &package).unwrap_or_else(|err| panic!("writing failed: {err}")); + + let handwritten = dir.write("restapi/notes.rs", "// mine, not the generator's\n"); + + let error = oapi_codegen::write_package(&output, &package) + .expect_err("writing should refuse to touch a directory holding a file it does not own"); + assert!( + matches!(&error, oapi_codegen::Error::UnownedOutput { path } if *path == handwritten.display().to_string()), + "expected `UnownedOutput` for `{}`, got: {error}", + handwritten.display(), + ); + assert!(handwritten.is_file(), "the refused file must survive"); + + let error = + oapi_codegen::check_package(&output, &package).expect_err("checking should refuse the same file as writing"); + assert!( + matches!(&error, oapi_codegen::Error::UnownedOutput { path } if *path == handwritten.display().to_string()), + "expected `UnownedOutput` for `{}`, got: {error}", + handwritten.display(), + ); +} + +#[test] +fn a_hand_written_file_where_a_generated_one_belongs_is_refused_and_kept() { + let dir = TestDir::new("unowned_expected"); + let output = dir.output(); + let package = package_for(&dir.spec(), &full_config(), &output); + + // The path a generated module occupies, holding somebody's work. A write + // that replaced it before checking ownership would lose that work. + let mine = "// mine, not the generator's\n"; + let occupied = dir.write("restapi/models.rs", mine); + + let error = + oapi_codegen::write_package(&output, &package).expect_err("writing must not replace a file it does not own"); + assert!( + matches!(&error, oapi_codegen::Error::UnownedOutput { path } if *path == occupied.display().to_string()), + "expected `UnownedOutput` for `{}`, got: {error}", + occupied.display(), + ); + assert_eq!( + std::fs::read_to_string(&occupied).unwrap_or_else(|err| panic!("reading failed: {err}")), + mine, + "the refused file must be untouched", + ); + assert!( + !output.exists(), + "a refused run must write nothing, not even the root file", + ); + + let error = + oapi_codegen::check_package(&output, &package).expect_err("checking must refuse the same file as writing"); + assert!( + matches!(&error, oapi_codegen::Error::UnownedOutput { .. }), + "expected `UnownedOutput`, got: {error}", + ); +} + +#[test] +fn a_symlink_in_the_companion_directory_is_refused() { + #[cfg(unix)] + { + let dir = TestDir::new("symlink"); + let output = dir.output(); + let package = package_for(&dir.spec(), &full_config(), &output); + oapi_codegen::write_package(&output, &package).unwrap_or_else(|err| panic!("writing failed: {err}")); + + // A link the walk must not follow. Its target carries the generated + // marker, so an ownership test that read through the link would delete + // a file outside the directory this run owns. + let outside = dir.write("outside.rs", "// Code generated by oapi-codegen-rust. DO NOT EDIT.\n"); + let link = dir.join("restapi/linked.rs"); + std::os::unix::fs::symlink(&outside, &link) + .unwrap_or_else(|err| panic!("linking `{}` failed: {err}", link.display())); + + let error = oapi_codegen::write_package(&output, &package).expect_err("writing must refuse a link"); + assert!( + matches!(&error, oapi_codegen::Error::UnownedOutput { path } if *path == link.display().to_string()), + "expected `UnownedOutput` for `{}`, got: {error}", + link.display(), + ); + assert!(outside.is_file(), "the link's target must survive"); + assert!(link.exists(), "the link itself must survive"); + } +} + +#[test] +fn an_output_path_with_no_extension_is_rejected_before_anything_is_written() { + let dir = TestDir::new("no_extension"); + let spec = dir.spec(); + // The companion directory is named after the output's stem, so an output + // with no extension would put the directory and the root file at one path. + let output = dir.join("restapi"); + + let error = oapi_codegen::generate_package(&spec, &full_config(), &output) + .expect_err("an output path with no extension cannot carry a companion directory"); + assert!( + matches!(&error, oapi_codegen::Error::UnsplittableOutput { path } if *path == output.display().to_string()), + "expected `UnsplittableOutput` for `{}`, got: {error}", + output.display(), + ); + assert!(!output.exists(), "nothing may be written"); +} + +#[test] +fn an_operation_named_after_a_keyword_gets_a_raw_module_and_a_plain_file() { + let dir = TestDir::new("keywords"); + let spec = dir.write( + "spec.yaml", + "\ +openapi: 3.0.3 +info: + title: Demo + version: 1.0.0 +paths: + /type: + get: + operationId: type + responses: + '200': + description: ok + /mod: + get: + operationId: mod + responses: + '200': + description: ok +", + ); + let package = package_for(&spec, &full_config(), &dir.output()); + + let paths = child_paths(&package); + assert!( + paths.contains(&"restapi/operations/type.rs".to_owned()), + "a keyword operation keeps its own file name: {paths:?}", + ); + // `mod.rs` beside `operations.rs` is the E0761 ambiguity, so only that name + // moves aside. The module item still carries the operation's identifier. + assert!( + paths.contains(&"restapi/operations/mod_.rs".to_owned()), + "`mod` must not claim `mod.rs`: {paths:?}", + ); + + let operations = package + .children() + .iter() + .find(|child| return child.path().ends_with("operations.rs")) + .unwrap_or_else(|| panic!("the package should hold an operations module: {paths:?}")); + assert!( + operations.source().contains("mod r#type;"), + "a keyword module needs its raw form:\n{}", + operations.source(), + ); + assert!( + operations + .source() + .contains("#[path = \"operations/mod_.rs\"]\nmod r#mod;"), + "the explicit path ties the moved file to the raw module:\n{}", + operations.source(), + ); +} + +#[test] +fn a_child_path_that_escapes_the_companion_directory_is_refused() { + let dir = TestDir::new("escape"); + let output = dir.output(); + let victim = dir.write("victim.rs", "// mine, not the generator's\n"); + + // The emitter never builds such a path, but the package types are public + // and every write below trusts that a child lands under the companion + // directory. + for escape in ["../victim.rs", "restapi/../../victim.rs"] { + let package = oapi_codegen::GeneratedPackage::new( + "// Code generated by oapi-codegen-rust. DO NOT EDIT.\n", + vec![oapi_codegen::GeneratedFile::new( + escape, + "// Code generated by oapi-codegen-rust. DO NOT EDIT.\n", + )], + ); + + let error = match oapi_codegen::write_package(&output, &package) { + Err(error) => error, + Ok(()) => panic!("writing `{escape}` should have been refused"), + }; + assert!( + matches!(&error, oapi_codegen::Error::OutsideOutput { .. }), + "expected `OutsideOutput` for `{escape}`, got: {error}", + ); + assert!( + oapi_codegen::check_package(&output, &package).is_err(), + "checking must refuse `{escape}` too", + ); + } + + assert_eq!( + std::fs::read_to_string(&victim).unwrap_or_else(|err| panic!("reading failed: {err}")), + "// mine, not the generator's\n", + "the file outside the companion directory must be untouched", + ); + assert!(!output.exists(), "a refused run must write nothing"); +} + +#[test] +fn writing_a_split_package_to_an_extensionless_path_is_refused() { + let dir = TestDir::new("write_no_extension"); + let package = package_for(&dir.spec(), &full_config(), &dir.output()); + // `generate_package` rejects this path, but `write_package` is public and + // must not half-write a package it cannot lay out. + let output = dir.join("restapi"); + + let error = oapi_codegen::write_package(&output, &package) + .expect_err("a package with children needs a companion directory beside the root"); + assert!( + matches!(&error, oapi_codegen::Error::UnsplittableOutput { .. }), + "expected `UnsplittableOutput`, got: {error}", + ); + assert!(!output.exists(), "nothing may be written"); +} + +#[test] +fn a_pipe_in_the_companion_directory_is_refused_without_reading_it() { + #[cfg(unix)] + { + let dir = TestDir::new("fifo"); + let output = dir.output(); + let package = package_for(&dir.spec(), &full_config(), &output); + oapi_codegen::write_package(&output, &package).unwrap_or_else(|err| panic!("writing failed: {err}")); + + // Opening a pipe with no writer blocks for ever. The generator writes + // only regular files, so the walk must refuse this without reading it. + let fifo = dir.join("restapi/pipe.rs"); + let status = std::process::Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap_or_else(|err| panic!("running mkfifo failed: {err}")); + assert!(status.success(), "mkfifo should create `{}`", fifo.display()); + + let error = oapi_codegen::check_package(&output, &package).expect_err("checking must refuse a pipe"); + assert!( + matches!(&error, oapi_codegen::Error::UnownedOutput { path } if *path == fifo.display().to_string()), + "expected `UnownedOutput` for `{}`, got: {error}", + fifo.display(), + ); + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 3a8b17c..061a6bb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,11 +27,11 @@ Keys mirror [`oapi-codegen`](https://github.com/oapi-codegen/oapi-codegen)'s YAML config; unknown keys are ignored, so an existing Go config can be reused as-is. Only the subset below is interpreted. -| Key | Type | Purpose | -| ---------------- | ------ | ---------------------------------------------------------------------- | -| `package` | string | Target module name (informational). | -| `output` | path | Output file path. | -| `import-mapping` | map | Map referenced spec files to external Rust modules (multi-file specs). | +| Key | Type | Purpose | +| ---------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `package` | string | Target module name (informational). | +| `output` | path | Root output file, ending in `.rs`. A run with operations adds a directory named after its stem beside it, and owns everything in that directory. | +| `import-mapping` | map | Map referenced spec files to external Rust modules (multi-file specs). | ### `generate` @@ -44,8 +44,8 @@ as-is. Only the subset below is interpreted. | `embedded-spec` | _Not implemented_ — rejected if set. | Setting both `std-http-server` and `client` emits models, per-operation types, -and both interfaces flat at the crate root, so the server and client share one -file and the same response types. +and both interfaces into one module tree, so the server and client share the same +response types. See [Build workflow](workflow.md) for the files a run writes. ## Dependencies diff --git a/docs/design.md b/docs/design.md index d2d1c9c..22fa51f 100644 --- a/docs/design.md +++ b/docs/design.md @@ -75,10 +75,19 @@ Supported keys: `x-go-*` keys are accepted but ignored. -## Combined output at the crate root +## Combined output in one namespace Generated models, per-operation types, server interfaces, and client interfaces -are emitted at the crate root in one file. +are split across a module tree, and the root file re-exports every module. One +namespace therefore still holds them all, and a consumer names any of them +through the root. + +The split follows the concerns the generator already has, not the shape of the +document. Each operation gets a file per concern it touches, so a spec change +that adds an operation adds files instead of growing one. A `mod.rs` is never +written: every child is declared with an explicit `#[path]`, which resolves the +same whether the root is mounted with `#[path]` or as a plain `mod`, and which +leaves no directory module to clash with the root file. If a generated per-operation type name conflicts with a component model name, generation fails. Use `x-rust-name` or `response-type-suffix` to resolve it. @@ -164,12 +173,13 @@ of them at once. ## One namespace holds every generated type -The flat layout puts every generated type at the crate root. Four kinds of type -share one namespace there. These are the component models, the inline schemas that -the generator hoists, the per-operation types, and the generator interfaces (`Api`, -`Client`, and `ClientError`). Any two of them that take one name emit two items with -that name, which does not compile. One check therefore holds one namespace and -reports every name that two items take. Three cases reach it. +Every generated type is re-exported from the root file, so one namespace holds +them all. Four kinds of type share it. These are the component models, the inline +schemas that the generator hoists, the per-operation types, and the generator +interfaces (`Api`, `Client`, and `ClientError`). Any two of them that take one +name emit two items with that name, which does not compile. One check therefore +holds one namespace and reports every name that two items take. Three cases reach +it. A hoisted inline schema takes its name from the property path that encloses it. The inline `bar` property of schema `Foo` gives `FooBar`, which is also the name that a diff --git a/docs/workflow.md b/docs/workflow.md index c3dba53..90999db 100644 --- a/docs/workflow.md +++ b/docs/workflow.md @@ -3,27 +3,76 @@ Generated code is a build input, and not a build product. Commit it, and let continuous integration confirm that it matches the spec. -## Commit the generated file +## Commit the generated files + +A run that generates operations writes a small module tree. This is the widest +it gets: + +```text +generated/restapi.rs the file you mount +generated/restapi/ + operations.rs + operations/.rs one file per operation: inputs and responses + models.rs the component schemas + server_urls.rs the constants for the spec's servers + server.rs the `Api` trait and the router + server/.rs one file per operation: extractors and handler + client.rs `Client`, `ClientError`, and the constructors + client/.rs one file per operation: the request method +``` + +The directory takes its name from the output file, and holds only the modules +the run has content for. Expect fewer than the tree above: + +- `operations.rs` and the files under it come with the operations themselves. +- `models.rs` needs a component schema to emit. A spec with none, and a spec + whose schemas all come from `import-mapping`, both get no `models.rs`. +- `server_urls.rs` needs `generate.server-urls` and a `servers` entry. +- `server.rs` needs `generate.std-http-server`, and `client.rs` needs + `generate.client`. A client-only run writes no `server.rs`. -The generator writes one Rust file. Put that file in version control next to the -hand-written code that uses it. +A run that generates models but no operations has nothing to split and writes +the single file alone. + +Put all of it in version control next to the hand-written code that uses it. This has three effects. A reader of a pull request sees what the spec change did to the API. A consumer of the crate builds it with no code generator and no OpenAPI document. A reviewer sees a change to a public type as a change to a committed file. +The generator owns the directory. Each run deletes the generated files in it that +the run no longer produces, so a renamed operation leaves nothing behind. That +covers a run that stops splitting altogether: a spec that loses its last +operation gets the single file back, and the directory goes with the modules it +held. Before it writes anything, a run reads every file already in the directory +and stops if it finds one that is not its own, which is any file without the +generated header and any symbolic link. Nothing is written or deleted when a run +stops that way, so a file that lands in the directory by mistake is never lost. + +Two configurations must not nest their outputs. An `output` of `generated/api.rs` +owns all of `generated/api/`, so no other configuration may write inside it. + +The output path needs a `.rs` extension, because the directory is named after the +file's stem. + ## Read the file with `#[path]` -The generated file opens with an inner attribute that turns off every lint that is -about hand-written source. Declare the file as a module, and rustc applies that -attribute to the file alone: +The root file re-exports every module, so one declaration reads the whole tree +and every generated name keeps the same path: ```rust,ignore -#[path = "generated/api.rs"] -pub mod api; +#[path = "generated/restapi.rs"] +pub mod restapi; ``` +`restapi::Api` and `restapi::ListBooksQuery` resolve as before, whichever file +holds them. + +The generated files open with an inner attribute that turns off every lint that is +about hand-written source. Declaring a file as a module makes rustc apply that +attribute to the file alone. + Do not use `include!`. It pastes the text into the module that calls it, and rustc rejects an inner attribute in a paste. @@ -51,14 +100,15 @@ ignore = ["**/generated/**"] ## Gate the build with `--check` -`--check` generates the code in memory and compares it with the output file. It +`--check` generates the code in memory and compares it with the files on disk. It writes nothing. -| Result | Exit code | -| --------------------------------- | --------- | -| The file holds the generated code | 0 | -| The file holds different content | 1 | -| The file does not exist | 1 | +| Result | Exit code | +| ----------------------------------------------- | --------- | +| Every file holds the generated code | 0 | +| A file holds different content | 1 | +| A file does not exist | 1 | +| An earlier run's file is there and now unneeded | 1 | A non-zero exit code stops the build, so a spec change with no regenerated output cannot merge. diff --git a/examples/bookstore/README.md b/examples/bookstore/README.md index 0332412..99f77e4 100644 --- a/examples/bookstore/README.md +++ b/examples/bookstore/README.md @@ -19,7 +19,7 @@ $ oapi-codegen --config-file oapi-codegen-catalog.yaml schemas/catalog.yaml cargo add serde@1.0.229 --features derive $ oapi-codegen --config-file oapi-codegen-server.yaml openapi.yaml -✓ wrote generated/restapi.rs +✓ wrote generated/restapi.rs and 15 modules beside it note: add the crates the generated code references to Cargo.toml: serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" @@ -34,7 +34,7 @@ $ oapi-codegen --config-file oapi-codegen-server.yaml openapi.yaml cargo add axum-extra@0.12.6 --features query $ oapi-codegen --config-file oapi-codegen-client.yaml openapi.yaml -✓ wrote generated/restclient.rs +✓ wrote generated/restclient.rs and 14 modules beside it note: add the crates the generated code references to Cargo.toml: serde_json = "1.0.151" http = "1.5.0" diff --git a/examples/bookstore/generated/apimodel/catalog.rs b/examples/bookstore/generated/apimodel/catalog.rs index db98719..610a531 100644 --- a/examples/bookstore/generated/apimodel/catalog.rs +++ b/examples/bookstore/generated/apimodel/catalog.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/examples/bookstore/generated/apimodel/common.rs b/examples/bookstore/generated/apimodel/common.rs index 84b0f05..4092be3 100644 --- a/examples/bookstore/generated/apimodel/common.rs +++ b/examples/bookstore/generated/apimodel/common.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, diff --git a/examples/bookstore/generated/restapi.rs b/examples/bookstore/generated/restapi.rs index f31371a..9f91f75 100644 --- a/examples/bookstore/generated/restapi.rs +++ b/examples/bookstore/generated/restapi.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, @@ -8,546 +9,12 @@ reason = "generated code, not first-party source" )] -/// `region` variable of the `Regional` server URL. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ServerUrlRegionalRegion { - Us, - Eu, -} - -impl ServerUrlRegionalRegion { - /// The wire value substituted into the server URL. - pub fn as_str(&self) -> &'static str { - return match self { - ServerUrlRegionalRegion::Us => "us", - ServerUrlRegionalRegion::Eu => "eu", - }; - } -} - -impl Default for ServerUrlRegionalRegion { - fn default() -> Self { - return ServerUrlRegionalRegion::Us; - } -} - -/// Production -pub const SERVER_URL_PRODUCTION: &str = "https://api.bookstore.example.com/v1"; - -/// Regional -pub fn server_url_regional(region: ServerUrlRegionalRegion) -> Result { - let mut url = String::from("https://{region}.api.bookstore.example.com/v1"); - url = url.replace("{region}", region.as_str()); - if url.contains('{') || url.contains('}') { - return Err( - format!("server URL still contains an unresolved placeholder: {url}"), - ); - } - return Ok(url); -} - -#[derive(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), -} - -#[derive(Debug, Clone)] -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(http::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), -} - -impl axum::extract::FromRequestParts for CreateBookHeaders -where - S: Send + Sync, -{ - type Rejection = (axum::http::StatusCode, String); - async fn from_request_parts( - parts: &mut axum::http::request::Parts, - _state: &S, - ) -> Result { - let idempotency_key = match parts.headers.get("Idempotency-Key") { - Some(value) => { - let text = match value.to_str() { - Ok(text) => text, - Err(_) => { - return Err(( - axum::http::StatusCode::BAD_REQUEST, - "header `Idempotency-Key` is not valid text".to_owned(), - )); - } - }; - text.to_owned() - } - None => { - return Err(( - axum::http::StatusCode::BAD_REQUEST, - "missing required header `Idempotency-Key`".to_owned(), - )); - } - }; - return Ok(Self { idempotency_key }); - } -} - -impl axum::extract::FromRequest for UploadBookCoverMultipart -where - S: Send + Sync, -{ - type Rejection = (axum::http::StatusCode, String); - async fn from_request( - request: axum::extract::Request, - state: &S, - ) -> Result { - let mut multipart = >::from_request(request, state) - .await - .map_err(|error| { - return (axum::http::StatusCode::BAD_REQUEST, error.to_string()); - })?; - let mut image: Option> = None; - let mut filename: Option = None; - let mut caption: Option = None; - while let Some(field) = multipart - .next_field() - .await - .map_err(|error| { - return (axum::http::StatusCode::BAD_REQUEST, error.to_string()); - })? - { - let field_name = field.name().map(|name| return name.to_owned()); - match field_name.as_deref() { - Some("image") => { - let value = field - .bytes() - .await - .map_err(|error| { - return ( - axum::http::StatusCode::BAD_REQUEST, - error.to_string(), - ); - })?; - image = Some(value.to_vec()); - } - Some("filename") => { - let value = field - .text() - .await - .map_err(|error| { - return ( - axum::http::StatusCode::BAD_REQUEST, - error.to_string(), - ); - })?; - filename = Some(value); - } - Some("caption") => { - let value = field - .text() - .await - .map_err(|error| { - return ( - axum::http::StatusCode::BAD_REQUEST, - error.to_string(), - ); - })?; - caption = Some(value); - } - _ => {} - } - } - return Ok(Self { - image: image - .ok_or(( - axum::http::StatusCode::BAD_REQUEST, - "missing required multipart field `image`".to_owned(), - ))?, - filename: filename - .ok_or(( - axum::http::StatusCode::BAD_REQUEST, - "missing required multipart field `filename`".to_owned(), - ))?, - caption, - }); - } -} - -impl axum::extract::FromRequest for SubmitReviewRequestBody -where - S: Send + Sync, -{ - type Rejection = (axum::http::StatusCode, String); - async fn from_request( - request: axum::extract::Request, - state: &S, - ) -> Result { - let content_type = request - .headers() - .get(axum::http::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(); - if content_type == "application/json" || content_type.ends_with("+json") { - let axum::Json(body) = as axum::extract::FromRequest>::from_request(request, state) - .await - .map_err(|error| { - return (axum::http::StatusCode::BAD_REQUEST, error.to_string()); - })?; - return Ok(SubmitReviewRequestBody::Json(body)); - } - if content_type == "application/x-www-form-urlencoded" { - let axum::Form(body) = as axum::extract::FromRequest>::from_request(request, state) - .await - .map_err(|error| { - return (axum::http::StatusCode::BAD_REQUEST, error.to_string()); - })?; - return Ok(SubmitReviewRequestBody::Form(body)); - } - if content_type.is_empty() { - return Err(( - axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, - "missing `Content-Type` header".to_owned(), - )); - } - return Err(( - axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, - format!("unsupported content type `{content_type}`"), - )); - } -} - -/// Server behaviour: implement one method per operation. -pub trait Api: Clone + Send + Sync + 'static { - /// List books, optionally filtered. - fn list_books( - &self, - query: ListBooksQuery, - ) -> impl std::future::Future + Send; - /// Add a book to the catalog. - fn create_book( - &self, - headers: CreateBookHeaders, - body: crate::apimodel::catalog::NewBook, - ) -> impl std::future::Future + Send; - /// Fetch a single book by id. - fn get_book( - &self, - id: String, - ) -> impl std::future::Future + Send; - /// Upload or replace a book's cover image. - fn upload_book_cover( - &self, - id: String, - body: UploadBookCoverMultipart, - ) -> impl std::future::Future + Send; - /// Submit a review as JSON or a form; read it back as JSON or plain text. - fn submit_review( - &self, - id: String, - body: SubmitReviewRequestBody, - ) -> impl std::future::Future + Send; - /// Liveness probe returning a free-form document. - fn get_health(&self) -> impl std::future::Future + Send; -} - -impl axum::response::IntoResponse for ListBooksResponse { - fn into_response(self) -> axum::response::Response { - match self { - ListBooksResponse::Ok(body) => { - const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( - 200, - ) { - Ok(status) => status, - Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), - }; - (STATUS, axum::Json(body)).into_response() - } - } - } -} - -impl axum::response::IntoResponse for CreateBookResponse { - fn into_response(self) -> axum::response::Response { - match self { - CreateBookResponse::Created(body) => { - const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( - 201, - ) { - Ok(status) => status, - Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), - }; - (STATUS, axum::Json(body)).into_response() - } - CreateBookResponse::BadRequest(body) => { - const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( - 400, - ) { - Ok(status) => status, - Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), - }; - (STATUS, axum::Json(body)).into_response() - } - CreateBookResponse::Unauthorized(body) => { - const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( - 401, - ) { - Ok(status) => status, - Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), - }; - (STATUS, axum::Json(body)).into_response() - } - } - } -} - -impl axum::response::IntoResponse for GetBookResponse { - fn into_response(self) -> axum::response::Response { - match self { - GetBookResponse::Ok(body) => { - const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( - 200, - ) { - Ok(status) => status, - Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), - }; - (STATUS, axum::Json(body)).into_response() - } - GetBookResponse::NotFound => { - const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( - 404, - ) { - Ok(status) => status, - Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), - }; - STATUS.into_response() - } - GetBookResponse::Default(status, body) => { - (status, axum::Json(body)).into_response() - } - } - } -} - -impl axum::response::IntoResponse for UploadBookCoverResponse { - fn into_response(self) -> axum::response::Response { - match self { - UploadBookCoverResponse::NoContent => { - const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( - 204, - ) { - Ok(status) => status, - Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), - }; - STATUS.into_response() - } - UploadBookCoverResponse::NotFound => { - const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( - 404, - ) { - Ok(status) => status, - Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), - }; - STATUS.into_response() - } - } - } -} - -impl axum::response::IntoResponse for SubmitReviewResponse { - fn into_response(self) -> axum::response::Response { - match self { - SubmitReviewResponse::Created(body) => { - const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( - 201, - ) { - Ok(status) => status, - Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), - }; - match body { - SubmitReviewResponseCreatedBody::Json(body) => { - (STATUS, axum::Json(body)).into_response() - } - SubmitReviewResponseCreatedBody::Text(body) => { - (STATUS, body).into_response() - } - } - } - SubmitReviewResponse::NotFound => { - const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( - 404, - ) { - Ok(status) => status, - Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), - }; - STATUS.into_response() - } - } - } -} - -impl axum::response::IntoResponse for GetHealthResponse { - fn into_response(self) -> axum::response::Response { - match self { - GetHealthResponse::Ok(body) => { - const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( - 200, - ) { - Ok(status) => status, - Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), - }; - (STATUS, axum::Json(body)).into_response() - } - } - } -} - -/// Build an axum `Router` that dispatches each route to `api`. -pub fn router(api: T) -> axum::Router { - axum::Router::new() - .route( - "/books", - axum::routing::get(list_books_handler::).post(create_book_handler::), - ) - .route("/books/{id}", axum::routing::get(get_book_handler::)) - .route("/books/{id}/cover", axum::routing::put(upload_book_cover_handler::)) - .route("/books/{id}/reviews", axum::routing::post(submit_review_handler::)) - .route("/health", axum::routing::get(get_health_handler::)) - .with_state(api) -} - -async fn list_books_handler( - axum::extract::State(api): axum::extract::State, - axum_extra::extract::Query(query): axum_extra::extract::Query, -) -> ListBooksResponse { - api.list_books(query).await -} - -async fn create_book_handler( - axum::extract::State(api): axum::extract::State, - headers: CreateBookHeaders, - axum::Json(body): axum::Json, -) -> CreateBookResponse { - api.create_book(headers, body).await -} - -async fn get_book_handler( - axum::extract::State(api): axum::extract::State, - axum::extract::Path(id): axum::extract::Path, -) -> GetBookResponse { - api.get_book(id).await -} - -async fn upload_book_cover_handler( - axum::extract::State(api): axum::extract::State, - axum::extract::Path(id): axum::extract::Path, - body: UploadBookCoverMultipart, -) -> UploadBookCoverResponse { - api.upload_book_cover(id, body).await -} - -async fn submit_review_handler( - axum::extract::State(api): axum::extract::State, - axum::extract::Path(id): axum::extract::Path, - body: SubmitReviewRequestBody, -) -> SubmitReviewResponse { - api.submit_review(id, body).await -} - -async fn get_health_handler( - axum::extract::State(api): axum::extract::State, -) -> GetHealthResponse { - api.get_health().await -} +#[path = "restapi/server_urls.rs"] +mod server_urls; +pub use server_urls::*; +#[path = "restapi/operations.rs"] +mod operations; +pub use operations::*; +#[path = "restapi/server.rs"] +mod server; +pub use server::*; diff --git a/examples/bookstore/generated/restapi/operations.rs b/examples/bookstore/generated/restapi/operations.rs new file mode 100644 index 0000000..7792d62 --- /dev/null +++ b/examples/bookstore/generated/restapi/operations.rs @@ -0,0 +1,29 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "operations/list_books.rs"] +mod list_books; +pub use list_books::*; +#[path = "operations/create_book.rs"] +mod create_book; +pub use create_book::*; +#[path = "operations/get_book.rs"] +mod get_book; +pub use get_book::*; +#[path = "operations/upload_book_cover.rs"] +mod upload_book_cover; +pub use upload_book_cover::*; +#[path = "operations/submit_review.rs"] +mod submit_review; +pub use submit_review::*; +#[path = "operations/get_health.rs"] +mod get_health; +pub use get_health::*; diff --git a/examples/bookstore/generated/restapi/operations/create_book.rs b/examples/bookstore/generated/restapi/operations/create_book.rs new file mode 100644 index 0000000..ee00bdb --- /dev/null +++ b/examples/bookstore/generated/restapi/operations/create_book.rs @@ -0,0 +1,27 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[derive(Debug, Clone)] +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), +} diff --git a/examples/bookstore/generated/restapi/operations/get_book.rs b/examples/bookstore/generated/restapi/operations/get_book.rs new file mode 100644 index 0000000..9d3fd07 --- /dev/null +++ b/examples/bookstore/generated/restapi/operations/get_book.rs @@ -0,0 +1,21 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +/// 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(http::StatusCode, crate::apimodel::common::ErrorResponse), +} diff --git a/examples/bookstore/generated/restapi/operations/get_health.rs b/examples/bookstore/generated/restapi/operations/get_health.rs new file mode 100644 index 0000000..266bc9b --- /dev/null +++ b/examples/bookstore/generated/restapi/operations/get_health.rs @@ -0,0 +1,17 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +/// Liveness probe returning a free-form document. +#[derive(Debug, Clone, PartialEq)] +pub enum GetHealthResponse { + /// An opaque service-health document. + Ok(serde_json::Value), +} diff --git a/examples/bookstore/generated/restapi/operations/list_books.rs b/examples/bookstore/generated/restapi/operations/list_books.rs new file mode 100644 index 0000000..9cdc278 --- /dev/null +++ b/examples/bookstore/generated/restapi/operations/list_books.rs @@ -0,0 +1,30 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[derive(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), +} diff --git a/examples/bookstore/generated/restapi/operations/submit_review.rs b/examples/bookstore/generated/restapi/operations/submit_review.rs new file mode 100644 index 0000000..0961047 --- /dev/null +++ b/examples/bookstore/generated/restapi/operations/submit_review.rs @@ -0,0 +1,31 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[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, +} diff --git a/examples/bookstore/generated/restapi/operations/upload_book_cover.rs b/examples/bookstore/generated/restapi/operations/upload_book_cover.rs new file mode 100644 index 0000000..957f1d9 --- /dev/null +++ b/examples/bookstore/generated/restapi/operations/upload_book_cover.rs @@ -0,0 +1,26 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[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, +} diff --git a/examples/bookstore/generated/restapi/server.rs b/examples/bookstore/generated/restapi/server.rs new file mode 100644 index 0000000..1b0b0cd --- /dev/null +++ b/examples/bookstore/generated/restapi/server.rs @@ -0,0 +1,79 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::operations::*; + +#[path = "server/list_books.rs"] +mod list_books; +use list_books::list_books_handler; +#[path = "server/create_book.rs"] +mod create_book; +use create_book::create_book_handler; +#[path = "server/get_book.rs"] +mod get_book; +use get_book::get_book_handler; +#[path = "server/upload_book_cover.rs"] +mod upload_book_cover; +use upload_book_cover::upload_book_cover_handler; +#[path = "server/submit_review.rs"] +mod submit_review; +use submit_review::submit_review_handler; +#[path = "server/get_health.rs"] +mod get_health; +use get_health::get_health_handler; + +/// Server behaviour: implement one method per operation. +pub trait Api: Clone + Send + Sync + 'static { + /// List books, optionally filtered. + fn list_books( + &self, + query: ListBooksQuery, + ) -> impl std::future::Future + Send; + /// Add a book to the catalog. + fn create_book( + &self, + headers: CreateBookHeaders, + body: crate::apimodel::catalog::NewBook, + ) -> impl std::future::Future + Send; + /// Fetch a single book by id. + fn get_book( + &self, + id: String, + ) -> impl std::future::Future + Send; + /// Upload or replace a book's cover image. + fn upload_book_cover( + &self, + id: String, + body: UploadBookCoverMultipart, + ) -> impl std::future::Future + Send; + /// Submit a review as JSON or a form; read it back as JSON or plain text. + fn submit_review( + &self, + id: String, + body: SubmitReviewRequestBody, + ) -> impl std::future::Future + Send; + /// Liveness probe returning a free-form document. + fn get_health(&self) -> impl std::future::Future + Send; +} + +/// Build an axum `Router` that dispatches each route to `api`. +pub fn router(api: T) -> axum::Router { + axum::Router::new() + .route( + "/books", + axum::routing::get(list_books_handler::).post(create_book_handler::), + ) + .route("/books/{id}", axum::routing::get(get_book_handler::)) + .route("/books/{id}/cover", axum::routing::put(upload_book_cover_handler::)) + .route("/books/{id}/reviews", axum::routing::post(submit_review_handler::)) + .route("/health", axum::routing::get(get_health_handler::)) + .with_state(api) +} diff --git a/examples/bookstore/generated/restapi/server/create_book.rs b/examples/bookstore/generated/restapi/server/create_book.rs new file mode 100644 index 0000000..448d64a --- /dev/null +++ b/examples/bookstore/generated/restapi/server/create_book.rs @@ -0,0 +1,88 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::operations::*; +use super::Api; + +impl axum::extract::FromRequestParts for CreateBookHeaders +where + S: Send + Sync, +{ + type Rejection = (axum::http::StatusCode, String); + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> Result { + let idempotency_key = match parts.headers.get("Idempotency-Key") { + Some(value) => { + let text = match value.to_str() { + Ok(text) => text, + Err(_) => { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + "header `Idempotency-Key` is not valid text".to_owned(), + )); + } + }; + text.to_owned() + } + None => { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + "missing required header `Idempotency-Key`".to_owned(), + )); + } + }; + return Ok(Self { idempotency_key }); + } +} + +impl axum::response::IntoResponse for CreateBookResponse { + fn into_response(self) -> axum::response::Response { + match self { + CreateBookResponse::Created(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 201, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + CreateBookResponse::BadRequest(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 400, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + CreateBookResponse::Unauthorized(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 401, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn create_book_handler( + axum::extract::State(api): axum::extract::State, + headers: CreateBookHeaders, + axum::Json(body): axum::Json, +) -> CreateBookResponse { + api.create_book(headers, body).await +} diff --git a/examples/bookstore/generated/restapi/server/get_book.rs b/examples/bookstore/generated/restapi/server/get_book.rs new file mode 100644 index 0000000..06b2de4 --- /dev/null +++ b/examples/bookstore/generated/restapi/server/get_book.rs @@ -0,0 +1,48 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for GetBookResponse { + fn into_response(self) -> axum::response::Response { + match self { + GetBookResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + GetBookResponse::NotFound => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 404, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + STATUS.into_response() + } + GetBookResponse::Default(status, body) => { + (status, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn get_book_handler( + axum::extract::State(api): axum::extract::State, + axum::extract::Path(id): axum::extract::Path, +) -> GetBookResponse { + api.get_book(id).await +} diff --git a/examples/bookstore/generated/restapi/server/get_health.rs b/examples/bookstore/generated/restapi/server/get_health.rs new file mode 100644 index 0000000..c071d52 --- /dev/null +++ b/examples/bookstore/generated/restapi/server/get_health.rs @@ -0,0 +1,35 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for GetHealthResponse { + fn into_response(self) -> axum::response::Response { + match self { + GetHealthResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn get_health_handler( + axum::extract::State(api): axum::extract::State, +) -> GetHealthResponse { + api.get_health().await +} diff --git a/examples/bookstore/generated/restapi/server/list_books.rs b/examples/bookstore/generated/restapi/server/list_books.rs new file mode 100644 index 0000000..cb0adbe --- /dev/null +++ b/examples/bookstore/generated/restapi/server/list_books.rs @@ -0,0 +1,36 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::operations::*; +use super::Api; + +impl axum::response::IntoResponse for ListBooksResponse { + fn into_response(self) -> axum::response::Response { + match self { + ListBooksResponse::Ok(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 200, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + (STATUS, axum::Json(body)).into_response() + } + } + } +} + +pub(super) async fn list_books_handler( + axum::extract::State(api): axum::extract::State, + axum_extra::extract::Query(query): axum_extra::extract::Query, +) -> ListBooksResponse { + api.list_books(query).await +} diff --git a/examples/bookstore/generated/restapi/server/submit_review.rs b/examples/bookstore/generated/restapi/server/submit_review.rs new file mode 100644 index 0000000..9e10231 --- /dev/null +++ b/examples/bookstore/generated/restapi/server/submit_review.rs @@ -0,0 +1,108 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::operations::*; +use super::Api; + +impl axum::extract::FromRequest for SubmitReviewRequestBody +where + S: Send + Sync, +{ + type Rejection = (axum::http::StatusCode, String); + async fn from_request( + request: axum::extract::Request, + state: &S, + ) -> Result { + let content_type = request + .headers() + .get(axum::http::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(); + if content_type == "application/json" || content_type.ends_with("+json") { + let axum::Json(body) = as axum::extract::FromRequest>::from_request(request, state) + .await + .map_err(|error| { + return (axum::http::StatusCode::BAD_REQUEST, error.to_string()); + })?; + return Ok(SubmitReviewRequestBody::Json(body)); + } + if content_type == "application/x-www-form-urlencoded" { + let axum::Form(body) = as axum::extract::FromRequest>::from_request(request, state) + .await + .map_err(|error| { + return (axum::http::StatusCode::BAD_REQUEST, error.to_string()); + })?; + return Ok(SubmitReviewRequestBody::Form(body)); + } + if content_type.is_empty() { + return Err(( + axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, + "missing `Content-Type` header".to_owned(), + )); + } + return Err(( + axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE, + format!("unsupported content type `{content_type}`"), + )); + } +} + +impl axum::response::IntoResponse for SubmitReviewResponse { + fn into_response(self) -> axum::response::Response { + match self { + SubmitReviewResponse::Created(body) => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 201, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + match body { + SubmitReviewResponseCreatedBody::Json(body) => { + (STATUS, axum::Json(body)).into_response() + } + SubmitReviewResponseCreatedBody::Text(body) => { + (STATUS, body).into_response() + } + } + } + SubmitReviewResponse::NotFound => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 404, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + STATUS.into_response() + } + } + } +} + +pub(super) async fn submit_review_handler( + axum::extract::State(api): axum::extract::State, + axum::extract::Path(id): axum::extract::Path, + body: SubmitReviewRequestBody, +) -> SubmitReviewResponse { + api.submit_review(id, body).await +} diff --git a/examples/bookstore/generated/restapi/server/upload_book_cover.rs b/examples/bookstore/generated/restapi/server/upload_book_cover.rs new file mode 100644 index 0000000..7303799 --- /dev/null +++ b/examples/bookstore/generated/restapi/server/upload_book_cover.rs @@ -0,0 +1,129 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::operations::*; +use super::Api; + +impl axum::extract::FromRequest for UploadBookCoverMultipart +where + S: Send + Sync, +{ + type Rejection = (axum::http::StatusCode, String); + async fn from_request( + request: axum::extract::Request, + state: &S, + ) -> Result { + let mut multipart = >::from_request(request, state) + .await + .map_err(|error| { + return (axum::http::StatusCode::BAD_REQUEST, error.to_string()); + })?; + let mut image: Option> = None; + let mut filename: Option = None; + let mut caption: Option = None; + while let Some(field) = multipart + .next_field() + .await + .map_err(|error| { + return (axum::http::StatusCode::BAD_REQUEST, error.to_string()); + })? + { + let field_name = field.name().map(|name| return name.to_owned()); + match field_name.as_deref() { + Some("image") => { + let value = field + .bytes() + .await + .map_err(|error| { + return ( + axum::http::StatusCode::BAD_REQUEST, + error.to_string(), + ); + })?; + image = Some(value.to_vec()); + } + Some("filename") => { + let value = field + .text() + .await + .map_err(|error| { + return ( + axum::http::StatusCode::BAD_REQUEST, + error.to_string(), + ); + })?; + filename = Some(value); + } + Some("caption") => { + let value = field + .text() + .await + .map_err(|error| { + return ( + axum::http::StatusCode::BAD_REQUEST, + error.to_string(), + ); + })?; + caption = Some(value); + } + _ => {} + } + } + return Ok(Self { + image: image + .ok_or(( + axum::http::StatusCode::BAD_REQUEST, + "missing required multipart field `image`".to_owned(), + ))?, + filename: filename + .ok_or(( + axum::http::StatusCode::BAD_REQUEST, + "missing required multipart field `filename`".to_owned(), + ))?, + caption, + }); + } +} + +impl axum::response::IntoResponse for UploadBookCoverResponse { + fn into_response(self) -> axum::response::Response { + match self { + UploadBookCoverResponse::NoContent => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 204, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + STATUS.into_response() + } + UploadBookCoverResponse::NotFound => { + const STATUS: axum::http::StatusCode = match axum::http::StatusCode::from_u16( + 404, + ) { + Ok(status) => status, + Err(_) => panic!("oapi-codegen emitted an invalid HTTP status code"), + }; + STATUS.into_response() + } + } + } +} + +pub(super) async fn upload_book_cover_handler( + axum::extract::State(api): axum::extract::State, + axum::extract::Path(id): axum::extract::Path, + body: UploadBookCoverMultipart, +) -> UploadBookCoverResponse { + api.upload_book_cover(id, body).await +} diff --git a/examples/bookstore/generated/restapi/server_urls.rs b/examples/bookstore/generated/restapi/server_urls.rs new file mode 100644 index 0000000..a5c2d19 --- /dev/null +++ b/examples/bookstore/generated/restapi/server_urls.rs @@ -0,0 +1,48 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +/// `region` variable of the `Regional` server URL. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServerUrlRegionalRegion { + Us, + Eu, +} + +impl ServerUrlRegionalRegion { + /// The wire value substituted into the server URL. + pub fn as_str(&self) -> &'static str { + return match self { + ServerUrlRegionalRegion::Us => "us", + ServerUrlRegionalRegion::Eu => "eu", + }; + } +} + +impl Default for ServerUrlRegionalRegion { + fn default() -> Self { + return ServerUrlRegionalRegion::Us; + } +} + +/// Production +pub const SERVER_URL_PRODUCTION: &str = "https://api.bookstore.example.com/v1"; + +/// Regional +pub fn server_url_regional(region: ServerUrlRegionalRegion) -> Result { + let mut url = String::from("https://{region}.api.bookstore.example.com/v1"); + url = url.replace("{region}", region.as_str()); + if url.contains('{') || url.contains('}') { + return Err( + format!("server URL still contains an unresolved placeholder: {url}"), + ); + } + return Ok(url); +} diff --git a/examples/bookstore/generated/restclient.rs b/examples/bookstore/generated/restclient.rs index acea7ce..2c9076f 100644 --- a/examples/bookstore/generated/restclient.rs +++ b/examples/bookstore/generated/restclient.rs @@ -1,6 +1,7 @@ // Code generated by oapi-codegen-rust. DO NOT EDIT. #![allow( dead_code, + unused_imports, clippy::all, clippy::pedantic, clippy::nursery, @@ -8,342 +9,9 @@ reason = "generated code, not first-party source" )] -#[derive(Debug, Clone, PartialEq)] -pub struct ListBooksQuery { - /// Restrict the listing to a single author. - pub author: Option, - /// Only return books carrying every given tag. - pub tag: Option>, - /// Maximum number of items to return. - pub limit: Option, -} - -/// List books, optionally filtered. -#[derive(Debug, Clone, PartialEq)] -pub enum ListBooksResponse { - /// The matching books. - Ok(Vec), -} - -#[derive(Debug, Clone)] -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(http::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), -} - -/// 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), - /// The response cannot be decoded: a body that failed to - /// deserialize (for example malformed form-urlencoded content), or a - /// required response header that was missing or unparsable. - 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: {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'~'); - -/// A blocking HTTP client for the API. -/// -/// `base_url` is used as a prefix for every request path and must not -/// carry a trailing slash (for example `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` (for example 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)); - } -} +#[path = "restclient/operations.rs"] +mod operations; +pub use operations::*; +#[path = "restclient/client.rs"] +mod client; +pub use client::*; diff --git a/examples/bookstore/generated/restclient/client.rs b/examples/bookstore/generated/restclient/client.rs new file mode 100644 index 0000000..0e562e7 --- /dev/null +++ b/examples/bookstore/generated/restclient/client.rs @@ -0,0 +1,110 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "client/list_books.rs"] +mod list_books; +#[path = "client/create_book.rs"] +mod create_book; +#[path = "client/get_book.rs"] +mod get_book; +#[path = "client/upload_book_cover.rs"] +mod upload_book_cover; +#[path = "client/submit_review.rs"] +mod submit_review; +#[path = "client/get_health.rs"] +mod get_health; + +/// 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), + /// The response cannot be decoded: a body that failed to + /// deserialize (for example malformed form-urlencoded content), or a + /// required response header that was missing or unparsable. + 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: {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'~'); + +/// A blocking HTTP client for the API. +/// +/// `base_url` is used as a prefix for every request path and must not +/// carry a trailing slash (for example `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` (for example preconfigured with timeouts). + pub fn with_client( + base_url: impl Into, + http: reqwest::blocking::Client, + ) -> Self { + return Self { + base_url: base_url.into(), + http, + }; + } +} diff --git a/examples/bookstore/generated/restclient/client/create_book.rs b/examples/bookstore/generated/restclient/client/create_book.rs new file mode 100644 index 0000000..05cb6c9 --- /dev/null +++ b/examples/bookstore/generated/restclient/client/create_book.rs @@ -0,0 +1,42 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + /// 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)); + } +} diff --git a/examples/bookstore/generated/restclient/client/get_book.rs b/examples/bookstore/generated/restclient/client/get_book.rs new file mode 100644 index 0000000..098c1c1 --- /dev/null +++ b/examples/bookstore/generated/restclient/client/get_book.rs @@ -0,0 +1,35 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::operations::*; +use super::{Client, ClientError}; +use super::PATH_PARAM_ENCODE_SET; + +impl Client { + /// 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)); + } +} diff --git a/examples/bookstore/generated/restclient/client/get_health.rs b/examples/bookstore/generated/restclient/client/get_health.rs new file mode 100644 index 0000000..3dcb2f7 --- /dev/null +++ b/examples/bookstore/generated/restclient/client/get_health.rs @@ -0,0 +1,27 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + /// 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/generated/restclient/client/list_books.rs b/examples/bookstore/generated/restclient/client/list_books.rs new file mode 100644 index 0000000..acea35e --- /dev/null +++ b/examples/bookstore/generated/restclient/client/list_books.rs @@ -0,0 +1,42 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::operations::*; +use super::{Client, ClientError}; + +impl Client { + /// 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)); + } +} diff --git a/examples/bookstore/generated/restclient/client/submit_review.rs b/examples/bookstore/generated/restclient/client/submit_review.rs new file mode 100644 index 0000000..a797373 --- /dev/null +++ b/examples/bookstore/generated/restclient/client/submit_review.rs @@ -0,0 +1,68 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::operations::*; +use super::{Client, ClientError}; +use super::PATH_PARAM_ENCODE_SET; + +impl Client { + /// 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)); + } +} diff --git a/examples/bookstore/generated/restclient/client/upload_book_cover.rs b/examples/bookstore/generated/restclient/client/upload_book_cover.rs new file mode 100644 index 0000000..5a2acc1 --- /dev/null +++ b/examples/bookstore/generated/restclient/client/upload_book_cover.rs @@ -0,0 +1,49 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +use super::super::operations::*; +use super::{Client, ClientError}; +use super::PATH_PARAM_ENCODE_SET; + +impl Client { + /// 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)); + } +} diff --git a/examples/bookstore/generated/restclient/operations.rs b/examples/bookstore/generated/restclient/operations.rs new file mode 100644 index 0000000..7792d62 --- /dev/null +++ b/examples/bookstore/generated/restclient/operations.rs @@ -0,0 +1,29 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[path = "operations/list_books.rs"] +mod list_books; +pub use list_books::*; +#[path = "operations/create_book.rs"] +mod create_book; +pub use create_book::*; +#[path = "operations/get_book.rs"] +mod get_book; +pub use get_book::*; +#[path = "operations/upload_book_cover.rs"] +mod upload_book_cover; +pub use upload_book_cover::*; +#[path = "operations/submit_review.rs"] +mod submit_review; +pub use submit_review::*; +#[path = "operations/get_health.rs"] +mod get_health; +pub use get_health::*; diff --git a/examples/bookstore/generated/restclient/operations/create_book.rs b/examples/bookstore/generated/restclient/operations/create_book.rs new file mode 100644 index 0000000..ee00bdb --- /dev/null +++ b/examples/bookstore/generated/restclient/operations/create_book.rs @@ -0,0 +1,27 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[derive(Debug, Clone)] +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), +} diff --git a/examples/bookstore/generated/restclient/operations/get_book.rs b/examples/bookstore/generated/restclient/operations/get_book.rs new file mode 100644 index 0000000..9d3fd07 --- /dev/null +++ b/examples/bookstore/generated/restclient/operations/get_book.rs @@ -0,0 +1,21 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +/// 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(http::StatusCode, crate::apimodel::common::ErrorResponse), +} diff --git a/examples/bookstore/generated/restclient/operations/get_health.rs b/examples/bookstore/generated/restclient/operations/get_health.rs new file mode 100644 index 0000000..266bc9b --- /dev/null +++ b/examples/bookstore/generated/restclient/operations/get_health.rs @@ -0,0 +1,17 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +/// Liveness probe returning a free-form document. +#[derive(Debug, Clone, PartialEq)] +pub enum GetHealthResponse { + /// An opaque service-health document. + Ok(serde_json::Value), +} diff --git a/examples/bookstore/generated/restclient/operations/list_books.rs b/examples/bookstore/generated/restclient/operations/list_books.rs new file mode 100644 index 0000000..4a1d9ea --- /dev/null +++ b/examples/bookstore/generated/restclient/operations/list_books.rs @@ -0,0 +1,27 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[derive(Debug, Clone, PartialEq)] +pub struct ListBooksQuery { + /// Restrict the listing to a single author. + pub author: Option, + /// Only return books carrying every given tag. + pub tag: Option>, + /// Maximum number of items to return. + pub limit: Option, +} + +/// List books, optionally filtered. +#[derive(Debug, Clone, PartialEq)] +pub enum ListBooksResponse { + /// The matching books. + Ok(Vec), +} diff --git a/examples/bookstore/generated/restclient/operations/submit_review.rs b/examples/bookstore/generated/restclient/operations/submit_review.rs new file mode 100644 index 0000000..0961047 --- /dev/null +++ b/examples/bookstore/generated/restclient/operations/submit_review.rs @@ -0,0 +1,31 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[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, +} diff --git a/examples/bookstore/generated/restclient/operations/upload_book_cover.rs b/examples/bookstore/generated/restclient/operations/upload_book_cover.rs new file mode 100644 index 0000000..957f1d9 --- /dev/null +++ b/examples/bookstore/generated/restclient/operations/upload_book_cover.rs @@ -0,0 +1,26 @@ +// Code generated by oapi-codegen-rust. DO NOT EDIT. +#![allow( + dead_code, + unused_imports, + clippy::all, + clippy::pedantic, + clippy::nursery, + clippy::restriction, + reason = "generated code, not first-party source" +)] + +#[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, +}